434 lines
12 KiB
Vue
434 lines
12 KiB
Vue
<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">编排多步骤的话术流程,引导用户按固定步骤完成信息收集。</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 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="200" 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="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="900px"
|
||
: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">步骤配置</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-input
|
||
v-model="element.content"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="请输入话术内容,支持 {{variable}} 占位符"
|
||
/>
|
||
</el-form-item>
|
||
<el-row :gutter="16">
|
||
<el-col :span="8">
|
||
<el-form-item label="等待输入">
|
||
<el-switch v-model="element.wait_for_input" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="8" v-if="element.wait_for_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_for_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>
|
||
</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>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Plus, Edit, Delete, View, Rank } from '@element-plus/icons-vue'
|
||
import draggable from 'vuedraggable'
|
||
import {
|
||
listScriptFlows,
|
||
createScriptFlow,
|
||
updateScriptFlow,
|
||
deleteScriptFlow,
|
||
getScriptFlow
|
||
} from '@/api/script-flow'
|
||
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
||
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep } from '@/types/script-flow'
|
||
import FlowPreview from './components/FlowPreview.vue'
|
||
|
||
const loading = ref(false)
|
||
const flows = ref<ScriptFlow[]>([])
|
||
const dialogVisible = ref(false)
|
||
const previewDrawer = ref(false)
|
||
const isEdit = ref(false)
|
||
const submitting = ref(false)
|
||
const formRef = 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
|
||
})
|
||
|
||
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 || [],
|
||
is_enabled: detail.is_enabled
|
||
}
|
||
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 addStep = () => {
|
||
formData.value.steps.push({
|
||
step_id: generateStepId(),
|
||
order: formData.value.steps.length + 1,
|
||
content: '',
|
||
wait_for_input: true,
|
||
timeout_seconds: 30,
|
||
timeout_action: 'repeat',
|
||
next_conditions: []
|
||
})
|
||
}
|
||
|
||
const removeStep = (index: number) => {
|
||
formData.value.steps.splice(index, 1)
|
||
formData.value.steps.forEach((step, i) => {
|
||
step.order = i + 1
|
||
})
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
try {
|
||
await formRef.value.validate()
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
if (formData.value.steps.length === 0) {
|
||
ElMessage.warning('请至少添加一个步骤')
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
try {
|
||
const submitData = {
|
||
...formData.value,
|
||
steps: formData.value.steps.map((step, index) => ({
|
||
...step,
|
||
order: 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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
</style>
|