ai-robot-core/ai-service-admin/src/views/admin/script-flow/index.vue

923 lines
31 KiB
Vue
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.

<template>
<div class="script-flow-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">话术流程管理</h1>
<p class="page-desc">编排多步骤的话术流程引导用户按固定步骤完成信息收集[AC-IDSMETA-16]</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建流程
</el-button>
</div>
</div>
</div>
<el-card shadow="hover" class="flow-card" v-loading="loading">
<el-table :data="flows" stripe style="width: 100%">
<el-table-column prop="name" label="流程名称" min-width="180" />
<el-table-column prop="description" label="描述" min-width="200">
<template #default="{ row }">
{{ row.description || '-' }}
</template>
</el-table-column>
<el-table-column prop="step_count" label="步骤数" width="100" />
<el-table-column prop="linked_rule_count" label="关联规则" width="100" />
<el-table-column label="元数据" width="120">
<template #default="{ row }">
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
{{ Object.keys(row.metadata).length }} 个字段
</el-tag>
<span v-else class="no-metadata">-</span>
</template>
</el-table-column>
<el-table-column prop="is_enabled" label="状态" width="80">
<template #default="{ row }">
<el-switch
v-model="row.is_enabled"
@change="handleToggleEnabled(row)"
active-color="#67C23A"
/>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="success" link size="small" @click="handleSimulate(row)">
<el-icon><VideoPlay /></el-icon>
模拟
</el-button>
<el-button type="info" link size="small" @click="handlePreview(row)">
<el-icon><View /></el-icon>
预览
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑流程' : '新建流程'"
width="950px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="流程名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入流程名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用状态">
<el-switch v-model="formData.is_enabled" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述">
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述(可选)" />
</el-form-item>
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
<MetadataForm
ref="metadataFormRef"
scope="script_flow"
v-model="formData.metadata"
:is-new-object="!isEdit"
:col-span="8"
/>
<el-divider content-position="left">步骤配置</el-divider>
<div class="steps-editor">
<draggable
v-model="formData.steps"
item-key="step_id"
handle=".drag-handle"
animation="200"
>
<template #item="{ element, index }">
<div class="step-item">
<div class="step-header">
<div class="drag-handle">
<el-icon><Rank /></el-icon>
</div>
<span class="step-order">步骤 {{ index + 1 }}</span>
<el-button type="danger" link size="small" @click="removeStep(index)">
删除
</el-button>
</div>
<div class="step-content">
<el-form-item label="话术模式">
<el-radio-group v-model="element.script_mode" size="small">
<el-radio-button
v-for="opt in SCRIPT_MODE_OPTIONS"
:key="opt.value"
:value="opt.value"
>
<el-tooltip :content="opt.description" placement="top">
<span>{{ opt.label }} <el-icon class="mode-help-icon"><QuestionFilled /></el-icon></span>
</el-tooltip>
</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="element.script_mode === 'fixed'">
<el-form-item label="话术内容" required>
<el-input
v-model="element.content"
type="textarea"
:rows="3"
placeholder="请输入固定话术内容"
/>
</el-form-item>
</template>
<template v-if="element.script_mode === 'flexible'">
<el-form-item label="步骤意图" required>
<el-input
v-model="element.intent"
placeholder="例如:获取用户姓名"
/>
</el-form-item>
<el-form-item label="意图说明">
<el-input
v-model="element.intent_description"
type="textarea"
:rows="2"
placeholder="详细描述这一步的目的和期望效果"
/>
</el-form-item>
<el-form-item label="话术约束">
<ConstraintManager v-model="element.script_constraints" />
</el-form-item>
<el-form-item label="Fallback话术" required>
<el-input
v-model="element.content"
type="textarea"
:rows="2"
placeholder="AI生成失败时使用的备用话术"
/>
</el-form-item>
<el-form-item label="期望变量">
<el-select
v-model="element.expected_variables"
multiple
filterable
placeholder="选择期望提取的槽位"
style="width: 100%"
>
<el-option
v-for="slot in availableSlots"
:key="slot.id"
:label="`${slot.slot_key} (${slot.type})`"
:value="slot.slot_key"
>
<div class="slot-option">
<span class="slot-key">{{ slot.slot_key }}</span>
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
<el-tag size="small" v-if="slot.required" type="danger">必填</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">期望变量必须引用已定义的槽位,步骤完成时会检查这些槽位是否已填充</div>
</el-form-item>
</template>
<template v-if="element.script_mode === 'template'">
<el-form-item label="话术模板" required>
<el-input
v-model="element.content"
type="textarea"
:rows="3"
placeholder="使用 {变量名} 标记可变部分,例如:您好{user_name},请问您{inquiry_style}"
/>
<div class="template-hint">
提示:使用 {变量名} 标记需要AI填充的部分
</div>
</el-form-item>
<el-form-item label="步骤意图">
<el-input
v-model="element.intent"
placeholder="可选:描述模板的使用场景"
/>
</el-form-item>
<el-form-item label="期望变量">
<el-select
v-model="element.expected_variables"
multiple
filterable
placeholder="选择期望提取的槽位"
style="width: 100%"
>
<el-option
v-for="slot in availableSlots"
:key="slot.id"
:label="`${slot.slot_key} (${slot.type})`"
:value="slot.slot_key"
>
<div class="slot-option">
<span class="slot-key">{{ slot.slot_key }}</span>
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
<el-tag size="small" v-if="slot.required" type="danger">必填</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">期望变量必须引用已定义的槽位,步骤完成时会检查这些槽位是否已填充</div>
</el-form-item>
</template>
<el-divider content-position="left">知识库范围</el-divider>
<div class="kb-binding-section">
<el-form-item label="允许的知识库">
<el-select
v-model="element.allowed_kb_ids"
multiple
filterable
clearable
placeholder="选择允许检索的知识库(为空则使用默认策略)"
style="width: 100%"
>
<el-option
v-for="kb in availableKnowledgeBases"
:key="kb.id"
:label="kb.name"
:value="kb.id"
>
<div class="kb-option">
<span>{{ kb.name }}</span>
<el-tag size="small" :type="getKbTypeTagType(kb.kbType)">{{ getKbTypeLabel(kb.kbType) }}</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">限制该步骤只能从选定的知识库中检索信息,为空则使用默认检索策略</div>
</el-form-item>
<el-form-item label="优先知识库">
<el-select
v-model="element.preferred_kb_ids"
multiple
filterable
clearable
placeholder="选择优先检索的知识库"
style="width: 100%"
>
<el-option
v-for="kb in availableKnowledgeBases"
:key="kb.id"
:label="kb.name"
:value="kb.id"
:disabled="element.allowed_kb_ids && element.allowed_kb_ids.length > 0 && !element.allowed_kb_ids.includes(kb.id)"
>
<div class="kb-option">
<span>{{ kb.name }}</span>
<el-tag size="small" :type="getKbTypeTagType(kb.kbType)">{{ getKbTypeLabel(kb.kbType) }}</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">检索时优先搜索这些知识库</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="16">
<el-form-item label="检索提示">
<el-input
v-model="element.kb_query_hint"
placeholder="可选:描述本步骤的检索意图,帮助提高检索准确性"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="最大检索次数">
<el-input-number
v-model="element.max_kb_calls_per_step"
:min="1"
:max="5"
placeholder="默认1"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="等待输入">
<el-switch v-model="element.wait_input" />
</el-form-item>
</el-col>
<el-col :span="8" v-if="element.wait_input">
<el-form-item label="超时(秒)">
<el-input-number v-model="element.timeout_seconds" :min="5" :max="300" />
</el-form-item>
</el-col>
<el-col :span="8" v-if="element.wait_input">
<el-form-item label="超时动作">
<el-select v-model="element.timeout_action" style="width: 100%;">
<el-option
v-for="opt in TIMEOUT_ACTION_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left" v-if="element.wait_input">分支跳转</el-divider>
<div v-if="element.wait_input" class="branch-editor">
<div
v-for="(cond, ci) in (element.next_conditions || [])"
:key="ci"
class="branch-item"
>
<el-row :gutter="8" align="middle">
<el-col :span="10">
<el-form-item label="关键词" label-width="60px" style="margin-bottom: 0;">
<el-select
v-model="cond.keywords"
multiple
filterable
allow-create
default-first-option
placeholder="输入关键词回车"
style="width: 100%"
size="small"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="正则" label-width="40px" style="margin-bottom: 0;">
<el-input
v-model="cond.pattern"
placeholder="可选"
size="small"
/>
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="跳转" label-width="40px" style="margin-bottom: 0;">
<el-select v-model="cond.goto_step" placeholder="步骤" size="small" style="width: 100%">
<el-option
v-for="(s, si) in formData.steps"
:key="si"
:label="'步骤 ' + (si + 1)"
:value="si + 1"
:disabled="si === index"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="3">
<el-button type="danger" link size="small" @click="removeBranch(element, ci)">删除</el-button>
</el-col>
</el-row>
</div>
<el-row :gutter="8" align="middle" style="margin-top: 8px;">
<el-col :span="16">
<el-button type="primary" link size="small" @click="addBranch(element)">
<el-icon><Plus /></el-icon> 添加分支条件
</el-button>
</el-col>
<el-col :span="8">
<el-form-item label="默认跳转" label-width="70px" style="margin-bottom: 0;">
<el-select v-model="element.default_next" placeholder="顺序" size="small" clearable style="width: 100%">
<el-option
v-for="(s, si) in formData.steps"
:key="si"
:label="'步骤 ' + (si + 1)"
:value="si + 1"
:disabled="si === index"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</div>
</template>
</draggable>
<el-button type="primary" plain @click="addStep" style="width: 100%; margin-top: 12px;">
<el-icon><Plus /></el-icon>
添加步骤
</el-button>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
<el-drawer v-model="previewDrawer" title="流程预览" size="500px" destroy-on-close>
<flow-preview v-if="currentFlow" :flow="currentFlow" />
</el-drawer>
<SimulateDialog
v-model:visible="simulateDialogVisible"
:flow-id="currentSimulateFlowId"
:flow-name="currentSimulateFlowName"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, View, Rank, VideoPlay, QuestionFilled } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import {
listScriptFlows,
createScriptFlow,
updateScriptFlow,
deleteScriptFlow,
getScriptFlow,
} from '@/api/script-flow'
import { slotDefinitionApi } from '@/api/slot-definition'
import { listKnowledgeBases } from '@/api/knowledge-base'
import { MetadataForm } from '@/components/metadata'
import { TIMEOUT_ACTION_OPTIONS, SCRIPT_MODE_OPTIONS } from '@/types/script-flow'
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep, ScriptMode } from '@/types/script-flow'
import type { SlotDefinition } from '@/types/slot-definition'
import type { KnowledgeBase } from '@/types/knowledge-base'
import { KB_TYPE_MAP } from '@/types/knowledge-base'
import FlowPreview from './components/FlowPreview.vue'
import SimulateDialog from './components/SimulateDialog.vue'
import ConstraintManager from './components/ConstraintManager.vue'
const loading = ref(false)
const flows = ref<ScriptFlow[]>([])
const availableSlots = ref<SlotDefinition[]>([])
const availableKnowledgeBases = ref<KnowledgeBase[]>([])
const dialogVisible = ref(false)
const previewDrawer = ref(false)
const simulateDialogVisible = ref(false)
const currentSimulateFlowId = ref('')
const currentSimulateFlowName = ref('')
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref()
const metadataFormRef = ref()
const currentFlow = ref<ScriptFlowDetail | null>(null)
const currentEditId = ref('')
const generateStepId = () => `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const defaultFormData = (): ScriptFlowCreate => ({
name: '',
description: '',
steps: [],
is_enabled: true,
metadata: {}
})
const formData = ref<ScriptFlowCreate>(defaultFormData())
const formRules = {
name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }]
}
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const loadFlows = async () => {
loading.value = true
try {
const res = await listScriptFlows()
flows.value = res.data || []
} catch (error) {
ElMessage.error('加载流程列表失败')
} finally {
loading.value = false
}
}
const loadAvailableSlots = async () => {
try {
const res = await slotDefinitionApi.list()
availableSlots.value = res || []
} catch (error) {
console.error('加载槽位定义失败', error)
availableSlots.value = []
}
}
const loadAvailableKnowledgeBases = async () => {
try {
const res = await listKnowledgeBases({ is_enabled: true })
availableKnowledgeBases.value = res.data || []
} catch (error) {
console.error('加载知识库列表失败', error)
availableKnowledgeBases.value = []
}
}
const getKbTypeLabel = (kbType: string): string => {
return KB_TYPE_MAP[kbType]?.label || kbType
}
const getKbTypeTagType = (kbType: string): string => {
const typeMap: Record<string, string> = {
product: 'primary',
faq: 'success',
script: 'warning',
policy: 'danger',
general: 'info'
}
return typeMap[kbType] || 'info'
}
const handleCreate = () => {
isEdit.value = false
currentEditId.value = ''
formData.value = defaultFormData()
loadAvailableSlots()
loadAvailableKnowledgeBases()
dialogVisible.value = true
}
const handleEdit = async (row: ScriptFlow) => {
isEdit.value = true
currentEditId.value = row.id
await loadAvailableSlots()
await loadAvailableKnowledgeBases()
try {
const detail = await getScriptFlow(row.id)
formData.value = {
name: detail.name,
description: detail.description || '',
steps: (detail.steps || []).map(step => ({
...step,
script_mode: step.script_mode || 'fixed',
script_constraints: step.script_constraints || [],
expected_variables: step.expected_variables || [],
allowed_kb_ids: step.allowed_kb_ids || [],
preferred_kb_ids: step.preferred_kb_ids || [],
kb_query_hint: step.kb_query_hint || '',
max_kb_calls_per_step: step.max_kb_calls_per_step || null
})),
is_enabled: detail.is_enabled,
metadata: detail.metadata || {}
}
dialogVisible.value = true
} catch (error) {
ElMessage.error('加载流程详情失败')
}
}
const handleDelete = async (row: ScriptFlow) => {
try {
await ElMessageBox.confirm('确定要删除该流程吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteScriptFlow(row.id)
ElMessage.success('删除成功')
loadFlows()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handleToggleEnabled = async (row: ScriptFlow) => {
try {
await updateScriptFlow(row.id, { is_enabled: row.is_enabled })
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
} catch (error) {
row.is_enabled = !row.is_enabled
ElMessage.error('操作失败')
}
}
const handlePreview = async (row: ScriptFlow) => {
try {
currentFlow.value = await getScriptFlow(row.id)
previewDrawer.value = true
} catch (error) {
ElMessage.error('加载流程详情失败')
}
}
const handleSimulate = (row: ScriptFlow) => {
currentSimulateFlowId.value = row.id
currentSimulateFlowName.value = row.name
simulateDialogVisible.value = true
}
const addStep = () => {
formData.value.steps.push({
step_id: generateStepId(),
step_no: formData.value.steps.length + 1,
content: '',
wait_input: true,
timeout_seconds: 30,
timeout_action: 'repeat',
next_conditions: [],
script_mode: 'fixed',
script_constraints: [],
expected_variables: []
})
}
const removeStep = (index: number) => {
const removedStepNo = index + 1
formData.value.steps.splice(index, 1)
formData.value.steps.forEach((step, i) => {
step.step_no = i + 1
if (step.next_conditions) {
step.next_conditions = step.next_conditions
.filter(c => c.goto_step !== removedStepNo)
.map(c => ({
...c,
goto_step: c.goto_step > removedStepNo ? c.goto_step - 1 : c.goto_step
}))
}
if (step.default_next !== undefined && step.default_next !== null) {
if (step.default_next === removedStepNo) {
step.default_next = undefined
} else if (step.default_next > removedStepNo) {
step.default_next = step.default_next - 1
}
}
})
}
const addBranch = (step: FlowStep) => {
if (!step.next_conditions) {
step.next_conditions = []
}
step.next_conditions.push({ keywords: [], goto_step: 0 })
}
const removeBranch = (step: FlowStep, index: number) => {
step.next_conditions?.splice(index, 1)
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
} catch {
return
}
if (metadataFormRef.value) {
const validation = await metadataFormRef.value.validate()
if (!validation.valid) {
ElMessage.warning('请完善必填的元数据字段')
return
}
}
if (formData.value.steps.length === 0) {
ElMessage.warning('请至少添加一个步骤')
return
}
for (let i = 0; i < formData.value.steps.length; i++) {
const step = formData.value.steps[i]
const stepLabel = `步骤 ${i + 1}`
if (step.script_mode === 'fixed' && !step.content?.trim()) {
ElMessage.warning(`${stepLabel}:固定模式需要填写话术内容`)
return
}
if (step.script_mode === 'flexible') {
if (!step.intent?.trim()) {
ElMessage.warning(`${stepLabel}:灵活模式需要填写步骤意图`)
return
}
if (!step.content?.trim()) {
ElMessage.warning(`${stepLabel}灵活模式需要填写Fallback话术`)
return
}
}
if (step.script_mode === 'template' && !step.content?.trim()) {
ElMessage.warning(`${stepLabel}:模板模式需要填写话术模板`)
return
}
}
submitting.value = true
try {
const submitData = {
...formData.value,
steps: formData.value.steps.map((step, index) => ({
...step,
step_no: index + 1
}))
}
if (isEdit.value) {
const updateData: ScriptFlowUpdate = submitData
await updateScriptFlow(currentEditId.value, updateData)
ElMessage.success('保存成功')
} else {
await createScriptFlow(submitData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadFlows()
} catch (error) {
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
loadFlows()
})
</script>
<style scoped>
.script-flow-page {
padding: 24px;
}
.page-header {
margin-bottom: 24px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.title-section {
flex: 1;
min-width: 300px;
}
.page-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.page-desc {
margin: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.flow-card {
border-radius: 8px;
}
.no-metadata {
color: var(--el-text-color-placeholder);
}
.steps-editor {
max-height: 400px;
overflow-y: auto;
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
}
.step-item {
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
}
.step-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background-color: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-light);
}
.drag-handle {
cursor: move;
color: var(--el-text-color-secondary);
}
.step-order {
font-weight: 600;
color: var(--el-text-color-primary);
}
.step-content {
padding: 16px;
}
.template-hint {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.mode-help-icon {
margin-left: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
vertical-align: middle;
}
.branch-editor {
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
margin-top: 8px;
}
.branch-item {
padding: 8px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
margin-bottom: 8px;
}
.slot-option {
display: flex;
align-items: center;
gap: 8px;
}
.slot-key {
font-weight: 500;
font-family: monospace;
}
.field-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.kb-binding-section {
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
margin-bottom: 12px;
}
.kb-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
</style>