chore: make codes the repository root
This commit is contained in:
65
web/src/views/ExecuteView.vue
Normal file
65
web/src/views/ExecuteView.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import type { ScenarioField, SOPNode, SOPRun } from '@/types'
|
||||
|
||||
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
|
||||
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
|
||||
const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
|
||||
const currentConfig=computed(()=>active.value?.node.config||{})
|
||||
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
|
||||
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}}
|
||||
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
function inputFor(field:ScenarioField){return field.field_type}
|
||||
function reset(){active.value=null;load()}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell execution-shell">
|
||||
<div class="page-heading"><div><h1>执行话术</h1><p>选择已发布 SOP,系统会根据客户回答给出下一步。</p></div></div>
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!active" class="sop-catalog">
|
||||
<button v-for="item in sops" :key="item.id" class="sop-entry surface" @click="start(item.id)"><span class="entry-seq">{{ String(item.id).padStart(2,'0') }}</span><div><small>{{ item.scenario_name }} · V{{ item.version }}</small><h2>{{ item.name }}</h2><p>{{ item.description || '按标准步骤执行这套话术流程。' }}</p></div><span class="play"><PlayCircleOutlined /></span></button>
|
||||
<a-empty v-if="!sops.length" description="还没有已发布的 SOP" />
|
||||
</div>
|
||||
<div v-else class="run-workspace">
|
||||
<aside class="run-context">
|
||||
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
|
||||
<h2>执行进行中</h2><p>客户回答会自动保存在当前执行记录中。</p>
|
||||
<div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div>
|
||||
<div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div>
|
||||
</aside>
|
||||
<main class="conversation surface">
|
||||
<div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div>
|
||||
<div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div>
|
||||
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.node.content }}</p><a-button type="primary" @click="reset">执行另一套 SOP</a-button></div>
|
||||
<template v-else>
|
||||
<a-form layout="vertical" class="answer-form">
|
||||
<a-form-item v-for="field in currentFields" :key="field.id" :label="field.field_name" :required="field.required">
|
||||
<a-input v-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" />
|
||||
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" />
|
||||
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" style="width:100%" />
|
||||
<a-radio-group v-else-if="inputFor(field)==='boolean'" v-model:value="answers[field.field_key]"><a-radio :value="true">是</a-radio><a-radio :value="false">否</a-radio></a-radio-group>
|
||||
<a-select v-else-if="inputFor(field)==='select'" v-model:value="answers[field.field_key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
|
||||
<a-select v-else-if="inputFor(field)==='multiselect'" v-model:value="answers[field.field_key]" mode="multiple" :options="(field.options||[]).map(value=>({value,label:value}))" />
|
||||
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" style="width:100%" />
|
||||
<a-input v-else v-model:value="answers[field.field_key]" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="active.node.type==='choice'&¤tFields.length===0"><a-radio-group v-model:value="answers[currentConfig.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group></a-form-item>
|
||||
</a-form>
|
||||
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}
|
||||
@media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user