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

743 lines
24 KiB
Vue
Raw Normal View History

<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
allow-create
default-first-option
placeholder="输入变量名后回车添加"
style="width: 100%"
/>
</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
allow-create
default-first-option
placeholder="输入变量名后回车添加"
style="width: 100%"
/>
</el-form-item>
</template>
<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 { 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 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 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 handleCreate = () => {
isEdit.value = false
currentEditId.value = ''
formData.value = defaultFormData()
dialogVisible.value = true
}
const handleEdit = async (row: ScriptFlow) => {
isEdit.value = true
currentEditId.value = row.id
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 || []
})),
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;
}
</style>