feat: add complete pet consultation SOP
This commit is contained in:
3
web/dist/assets/SOPEditorView-2uB6hvkh.js
vendored
Normal file
3
web/dist/assets/SOPEditorView-2uB6hvkh.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/SOPEditorView-C7DDP1Zg.css
vendored
Normal file
1
web/dist/assets/SOPEditorView-C7DDP1Zg.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.editor-page[data-v-a5b6c482]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-a5b6c482]{border-bottom:1px solid var(--line);z-index:10;background:#fff;justify-content:space-between;align-items:center;gap:16px;min-height:66px;padding:10px 24px;display:flex;position:sticky;top:62px}.editor-title[data-v-a5b6c482]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-a5b6c482]{flex-direction:column;display:flex}.editor-title b[data-v-a5b6c482]{font-size:15px}.editor-title span[data-v-a5b6c482]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-a5b6c482]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-a5b6c482]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-a5b6c482]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;height:50px;padding:0 14px;font-weight:650;display:flex}.node-list[data-v-a5b6c482]{padding:8px}.node-list button[data-v-a5b6c482]{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:4px;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;width:100%;min-height:58px;padding:7px 8px;display:grid}.node-list button[data-v-a5b6c482]:hover{background:#f5f8f6}.node-list button.active[data-v-a5b6c482]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-a5b6c482]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-a5b6c482]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-a5b6c482]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-a5b6c482]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-a5b6c482]{color:#87938e;gap:4px;display:flex}.node-move[data-v-a5b6c482]>:hover{color:var(--green)}.node-editor[data-v-a5b6c482]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-a5b6c482]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;padding-bottom:18px;display:flex}.node-editor-head h2[data-v-a5b6c482]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-a5b6c482]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-a5b6c482]{padding-top:22px}.property-grid[data-v-a5b6c482]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-a5b6c482]{border-top:1px solid var(--line);justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;display:flex}.transition-head>div[data-v-a5b6c482]{flex-direction:column;display:flex}.transition-head span[data-v-a5b6c482]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-a5b6c482]{border-bottom:1px solid #edf0ee;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;min-height:48px;display:grid}.route-line[data-v-a5b6c482]{background:var(--green);width:16px;height:1px}.route-target[data-v-a5b6c482]{font-weight:600}.route-empty[data-v-a5b6c482]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-a5b6c482]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-a5b6c482]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-a5b6c482]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-a5b6c482]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-a5b6c482]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-a5b6c482]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-a5b6c482]{position:static}.property-grid[data-v-a5b6c482],.edge-builder[data-v-a5b6c482]{grid-template-columns:1fr}.node-editor[data-v-a5b6c482]{padding:16px}.transition-head[data-v-a5b6c482]{margin:8px -16px 0;padding:16px}}
|
||||
3
web/dist/assets/SOPEditorView-CmiIrIPT.js
vendored
3
web/dist/assets/SOPEditorView-CmiIrIPT.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/SOPEditorView-DdGSx6p0.css
vendored
1
web/dist/assets/SOPEditorView-DdGSx6p0.css
vendored
@@ -1 +0,0 @@
|
||||
.editor-page[data-v-06435891]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-06435891]{border-bottom:1px solid var(--line);z-index:10;background:#fff;justify-content:space-between;align-items:center;gap:16px;min-height:66px;padding:10px 24px;display:flex;position:sticky;top:62px}.editor-title[data-v-06435891]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-06435891]{flex-direction:column;display:flex}.editor-title b[data-v-06435891]{font-size:15px}.editor-title span[data-v-06435891]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-06435891]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-06435891]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-06435891]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;height:50px;padding:0 14px;font-weight:650;display:flex}.node-list[data-v-06435891]{padding:8px}.node-list button[data-v-06435891]{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:4px;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;width:100%;min-height:58px;padding:7px 8px;display:grid}.node-list button[data-v-06435891]:hover{background:#f5f8f6}.node-list button.active[data-v-06435891]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-06435891]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-06435891]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-06435891]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-06435891]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-06435891]{color:#87938e;gap:4px;display:flex}.node-move[data-v-06435891]>:hover{color:var(--green)}.node-editor[data-v-06435891]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-06435891]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;padding-bottom:18px;display:flex}.node-editor-head h2[data-v-06435891]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-06435891]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-06435891]{padding-top:22px}.property-grid[data-v-06435891]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-06435891]{border-top:1px solid var(--line);justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;display:flex}.transition-head>div[data-v-06435891]{flex-direction:column;display:flex}.transition-head span[data-v-06435891]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-06435891]{border-bottom:1px solid #edf0ee;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;min-height:48px;display:grid}.route-line[data-v-06435891]{background:var(--green);width:16px;height:1px}.route-target[data-v-06435891]{font-weight:600}.route-empty[data-v-06435891]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-06435891]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-06435891]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-06435891]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-06435891]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-06435891]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-06435891]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-06435891]{position:static}.property-grid[data-v-06435891],.edge-builder[data-v-06435891]{grid-template-columns:1fr}.node-editor[data-v-06435891]{padding:16px}.transition-head[data-v-06435891]{margin:8px -16px 0;padding:16px}}
|
||||
File diff suppressed because one or more lines are too long
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#202825" />
|
||||
<title>销冠 SOP 平台</title>
|
||||
<script type="module" crossorigin src="/assets/index-DquTypIs.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CsZhTuSf.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/client-CO11mUW5.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-kwhtQ-D4.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/auth-D7KJ41TQ.js">
|
||||
|
||||
@@ -4,16 +4,19 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import type { SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
|
||||
import type { ScenarioField, SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
|
||||
|
||||
interface KnowledgeCard { id:number; scenario_id:number; title:string }
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
|
||||
const loading = ref(true); const saving = ref(false)
|
||||
const sop = ref<SOP | null>(null); const version = ref<SOPVersion | null>(null)
|
||||
const nodes = ref<SOPNode[]>([]); const edges = ref<SOPEdge[]>([]); const selectedKey = ref('')
|
||||
const fields = ref<ScenarioField[]>([]); const knowledgeCards = ref<KnowledgeCard[]>([])
|
||||
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
|
||||
const editable = computed(() => version.value?.status === 'draft')
|
||||
const reviewing = computed(() => version.value?.status === 'reviewing')
|
||||
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
|
||||
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',label:'信息表单'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
|
||||
const edgeDraft = reactive({ target_node_key: '', field: '', operator: 'equals', value: '' })
|
||||
|
||||
async function load() {
|
||||
@@ -21,6 +24,11 @@ async function load() {
|
||||
try {
|
||||
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
|
||||
sop.value=data.sop; version.value=data.version; nodes.value=data.nodes.map(n=>({...n,config:n.config||{}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value ||= nodes.value[0]?.node_key || ''
|
||||
const [scenarioData, cardData] = await Promise.all([
|
||||
api.get<{ fields:ScenarioField[] }>(`/scenarios/${data.sop.scenario_id}`),
|
||||
api.get<{ items:KnowledgeCard[] }>('/knowledge-cards'),
|
||||
])
|
||||
fields.value=scenarioData.fields; knowledgeCards.value=cardData.items.filter(card=>card.scenario_id===data.sop.scenario_id)
|
||||
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
|
||||
}
|
||||
function addNode() {
|
||||
@@ -35,7 +43,7 @@ function outgoing(key:string){return edges.value.filter(e=>e.source_node_key===k
|
||||
function addEdge(){if(!selected.value||!edgeDraft.target_node_key)return;const condition=edgeDraft.field?{field:edgeDraft.field,operator:edgeDraft.operator,value:parseValue(edgeDraft.value)}:{};edges.value.push({id:0,created_at:'',updated_at:'',source_node_key:selected.value.node_key,target_node_key:edgeDraft.target_node_key,condition,priority:outgoing(selected.value.node_key).length});Object.assign(edgeDraft,{target_node_key:'',field:'',operator:'equals',value:''})}
|
||||
function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(e=>e!==edge)}
|
||||
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
|
||||
function conditionText(condition:Record<string,any>){if(!condition||!condition.field)return '默认路径';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',exists:'已填写',not_exists:'未填写'};return `${condition.field} ${names[condition.operator]||condition.operator} ${condition.value ?? ''}`}
|
||||
function conditionText(condition:Record<string,any>):string{if(!condition||Object.keys(condition).length===0)return '默认路径';if(Array.isArray(condition.any))return `任一:${condition.any.map(conditionText).join(';')}`;if(Array.isArray(condition.all))return `全部:${condition.all.map(conditionText).join(';')}`;if(!condition.field)return '未识别条件';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',exists:'已填写',not_exists:'未填写'};const fieldName=fields.value.find(field=>field.field_key===condition.field)?.field_name||condition.field;return `${fieldName} ${names[condition.operator]||condition.operator} ${condition.value ?? ''}`}
|
||||
async function save(){saving.value=true;try{await api.put(`/sops/${id}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
|
||||
async function validate(){try{const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join(';')})}catch(error){message.error(apiMessage(error))}}
|
||||
async function submitReview(){try{await save();await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
|
||||
@@ -64,8 +72,10 @@ onMounted(load)
|
||||
<a-form layout="vertical" class="property-form">
|
||||
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!editable" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!editable||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
|
||||
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!editable" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
|
||||
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入字段标识"><a-input v-model:value="selected.config.field_key" :disabled="!editable" placeholder="例如 pet_weight" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!editable" /></a-form-item></div></template>
|
||||
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!editable" /></a-form-item></div></template>
|
||||
<a-form-item v-if="selected.type==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
|
||||
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!editable" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
||||
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!editable" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></a-form-item>
|
||||
</a-form>
|
||||
<div class="transition-head"><div><b>下一步路径</b><span>按优先级匹配,默认路径建议放最后</span></div></div>
|
||||
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="editable" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
|
||||
|
||||
Reference in New Issue
Block a user