This commit is contained in:
Eric 1549169735@qq.com
2026-09-13 20:08:13 +08:00
parent 7cc06976ab
commit da3f16e6db
65 changed files with 3855 additions and 2153 deletions

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { CodeOutlined, SaveOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import type { ScenarioField } from '@/types'
interface KnowledgeItem { id?:number; key?:string; item_key?:string; name:string; type:string; content:Record<string,unknown>; status:string; sort_order:number }
interface KnowledgeRelation { from?:string; to?:string; relation_type:string; from_knowledge_id?:number; to_knowledge_id?:number; condition?:Record<string,unknown>; sort_order:number }
const props = defineProps<{ scenarioId:number; fields:ScenarioField[]; editable:boolean }>()
const loading=ref(false);const saving=ref(false);const jsonText=ref('{\n "items": [],\n "relations": []\n}')
const parsed=computed(()=>{try{return JSON.parse(jsonText.value)}catch{return null}})
async function load(){loading.value=true;try{const data=await api.get<{items:KnowledgeItem[];relations:KnowledgeRelation[]}>(`/scenarios/${props.scenarioId}/knowledge-graph`);const keyById=new Map(data.items.map(item=>[item.id,item.item_key]));jsonText.value=JSON.stringify({items:data.items.map(item=>({key:item.item_key,name:item.name,type:item.type,content:item.content||{},status:item.status,sort_order:item.sort_order})),relations:data.relations.map(rel=>({from:keyById.get(rel.from_knowledge_id),relation_type:rel.relation_type,to:keyById.get(rel.to_knowledge_id),condition:rel.condition||{},sort_order:rel.sort_order}))},null,2)}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
watch(()=>props.scenarioId,load,{immediate:true})
async function save(){if(!parsed.value)return message.warning('知识 JSON 格式不正确');saving.value=true;try{await api.put(`/scenarios/${props.scenarioId}/knowledge-graph`,parsed.value);message.success('场景知识已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
</script>
<template>
<section class="surface knowledge-editor">
<header><div><span class="icon"><CodeOutlined /></span><div><b>场景知识 JSON</b><small>定义知识实体与关系知识类型和关系类型均由场景决定</small></div></div><a-button v-if="editable" type="primary" :loading="saving" @click="save"><SaveOutlined />保存知识</a-button></header>
<a-alert type="info" show-icon message="通用格式items + relations。宠物场景也支持 symptoms[].diseases[],一个症状可关联多个疾病。" />
<a-spin :spinning="loading"><a-textarea v-model:value="jsonText" class="json-input" :disabled="!editable" :auto-size="{minRows:22,maxRows:38}" spellcheck="false" /></a-spin>
<footer><span>{{ parsed ? 'JSON 格式有效' : 'JSON 格式错误' }}</span><span>{{ fields.length }} 个场景输入字段可供规则和话术引用</span></footer>
</section>
</template>
<style scoped>
.knowledge-editor{padding:18px}.knowledge-editor header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.knowledge-editor header>div{display:flex;align-items:center;gap:10px}.knowledge-editor header div div{display:flex;flex-direction:column}.knowledge-editor small{margin-top:3px;color:var(--muted)}.icon{width:34px;height:34px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.json-input{margin-top:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.6}.knowledge-editor footer{display:flex;justify-content:space-between;margin-top:10px;color:var(--muted);font-size:11px}@media(max-width:700px){.knowledge-editor header{align-items:flex-start;flex-direction:column}.knowledge-editor footer{gap:8px;flex-direction:column}}
</style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
const props = defineProps<{ scenarioId:number; editable:boolean }>()
const loading=ref(false);const saving=ref(false)
const jsonText=ref('')
const statusText=ref('')
async function load(){loading.value=true;try{const data=await api.get<Record<string,unknown>>(`/scenarios/${props.scenarioId}/script-package`);statusText.value=`话术包 #${data.id||'-'} · ${data.status||'active'}`;const {id:_id,status:_status,...document}=data as Record<string,unknown>;jsonText.value=JSON.stringify(document,null,2)}catch(error){if(!apiMessage(error).includes('不存在'))message.error(apiMessage(error))}finally{loading.value=false}}
function parseJSON(){try{return JSON.parse(jsonText.value)}catch{message.warning('话术包 JSON 格式不正确');return null}}
async function save(){const payload=parseJSON();if(!payload)return;saving.value=true;try{await api.put(`/scenarios/${props.scenarioId}/script-package`,payload);message.success('话术包已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
load()
</script>
<template>
<section class="surface scriptkit-editor">
<header>
<div><span class="icon"></span><div><b>话术包</b><small>维度维度值阶段话术选项阈值联动与入场适配</small></div></div>
<a-button v-if="editable" type="primary" :loading="saving" @click="save">保存话术包</a-button>
</header>
<p class="status-line">{{ statusText }}</p>
<a-textarea v-model:value="jsonText" :disabled="!editable" class="json-input" :auto-size="{minRows:18,maxRows:60}" spellcheck="false" placeholder='{"name":"...","start_stage_key":"opening","dimensions":[...],"stages":[...],"linkages":[...],"adapters":[...]}' />
<footer>
<span>话术包是问诊问药的核心配置运行时按维度权重匹配话术答案触发升维/降维并联动后续维度</span>
<span v-if="!editable">只读</span>
</footer>
</section>
</template>
<style scoped>
.scriptkit-editor{padding:18px}.scriptkit-editor header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.scriptkit-editor header>div{display:flex;align-items:center;gap:10px}.scriptkit-editor header div div{display:flex;flex-direction:column}.scriptkit-editor small{margin-top:3px;color:var(--muted)}.icon{width:34px;height:34px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px;font-weight:700}.status-line{margin:0 0 8px;color:var(--muted);font-size:12px}.json-input{margin-top:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.6}.scriptkit-editor footer{display:flex;justify-content:space-between;margin-top:10px;color:var(--muted);font-size:11px}@media(max-width:700px){.scriptkit-editor header{align-items:flex-start;flex-direction:column}.scriptkit-editor footer{gap:8px;flex-direction:column}}
</style>

View File

@@ -53,8 +53,8 @@ function logout() {
<div class="brand" :class="{ compact: collapsed }">
<div class="brand-mark"><span></span><span></span><span></span></div>
<div v-if="!collapsed" class="brand-copy">
<strong> SOP</strong>
<small>经验执行系统</small>
<strong>话通</strong>
<small>AI 追单话术系统</small>
</div>
</div>
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">

View File

@@ -3,19 +3,23 @@ export interface User { user_id: number; tenant_id: number; role_code: string; p
export interface Scenario extends BaseEntity { tenant_id: number; scenario_key:string; public_key:string; allowed_origins:string[]; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number; input_schema:Record<string,unknown>; output_schema:Record<string,unknown>; result_schema:Record<string,unknown> }
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; source_path:string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string; start_node_key?: string }
export interface KnowledgeItemView { key:string; name:string; type:string; content:Record<string,any> }
export interface KnowledgeGroup extends KnowledgeItemView { relations:Record<string,KnowledgeItemView[]>; suggested?:boolean }
export interface PublicFieldView { key:string; name:string; type:string; required:boolean; options:string[]; validation:Record<string,unknown> }
export interface KnowledgeCollectionOption { value:string; label:string; parents?:string[]; suggested?:boolean }
export interface KnowledgeCollectionStep { field_key:string; name:string; required:boolean; multiple:boolean; from_field?:string; options:KnowledgeCollectionOption[] }
export interface KnowledgeCollection { context_fields:PublicFieldView[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] }
export interface DimensionValue { value_key:string; name:string; weight:number; hot:boolean }
export interface DimensionState { dim_key:string; name:string; values:DimensionValue[] }
export interface ScriptOption { option_key:string; label:string }
export interface ScriptProduct { sku_code:string; product_name:string }
export interface ScriptItem { script_key:string; name:string; script_type:string; content:string; dimension_value_key?:string; weight?:number; confirm_threshold?:number; show_threshold?:number; options?:ScriptOption[]; products?:ScriptProduct[]; feedback:boolean; required:boolean; multiple:boolean }
export interface StageForm { fields:PublicFieldView[] }
export interface StageState { stage_key:string; name:string; purpose:string; dimensions:DimensionState[]; scripts:ScriptItem[]; fallback?:string; form?:StageForm; screening_required:boolean; can_next:boolean; can_next_reason?:string }
export interface PublicFieldView { key:string; name:string; type:string; required:boolean; options:unknown[]; validation:Record<string,unknown> }
export interface PresentationField { key:string; label:string; kind:'text'|'image'; value:unknown }
export interface NodePresentation { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} }
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; presentation?:NodePresentation; outputs?:KnowledgeGroup[]; collection?:KnowledgeCollection }
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; presentation?:NodePresentation; stage?:StageState }
export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record<string, any>; priority: number }
export interface SOPRun extends BaseEntity { sop_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record<string, any>; input:Record<string,any>; derived:Record<string,any>; outputs:unknown[]; result: string; final_result?:Record<string,unknown>|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string }
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string; outputs?:KnowledgeGroup[] }
export interface SOPRun extends BaseEntity { sop_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record<string, any>; input:Record<string,any>; derived:Record<string,any>; script_state?:Record<string,any>; outputs:unknown[]; result: string; final_result?:Record<string,unknown>|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string }
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
export interface RunDimension extends BaseEntity { run_id:number; dimension_id:number; value_key:string; weight:number }
export interface ScriptFeedback extends BaseEntity { run_id:number; stage_id:number; script_id:number; feedback_type:string; operator_id:number; note:string }
export interface RunDetail extends SOPRun { scenario_name:string; operator_name:string }
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }
export interface Member { id:number; user_id:number; username:string; display_name:string; role_code:string; role_name:string; status:string }

View File

@@ -9,9 +9,16 @@ const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const summary = ref({ scenarios: 0, published_sops: 0, runs: 0, completed_runs: 0 })
interface DimensionStat { value_key:string; name:string; dim_key:string; runs:number; average_weight:number }
interface FeedbackStat { script_key:string; name:string; feedback_type:string; count:number }
const scriptkit = ref<{ dimensions:DimensionStat[]; feedback:FeedbackStat[] }>({ dimensions: [], feedback: [] })
const feedbackTypeText = (type:string)=>({ like:'有帮助', report:'不准确', unreasonable:'不合理' } as Record<string,string>)[type]||type
onMounted(async () => {
try { summary.value = await api.get('/dashboard/summary') } finally { loading.value = false }
try {
summary.value = await api.get('/dashboard/summary')
scriptkit.value = await api.get('/dashboard/scriptkit')
} catch { /* 看板统计失败不阻塞页面 */ } finally { loading.value = false }
})
</script>
@@ -29,22 +36,44 @@ onMounted(async () => {
<div class="metric"><span class="metric-icon amber"><PlayCircleOutlined /></span><div><b>{{ summary.runs }}</b><small>累计执行</small></div></div>
<div class="metric"><span class="completion">{{ summary.runs ? Math.round(summary.completed_runs / summary.runs * 100) : 0 }}%</span><div><b>{{ summary.completed_runs }}</b><small>完成执行</small></div></div>
</section>
</a-skeleton>
<section class="work-grid">
<div class="surface action-panel">
<div class="panel-kicker">常用入口</div>
<button v-if="auth.can('scenario.edit')" @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段目标和触发条件</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('sop.execute')" @click="router.push('/execute')"><span>开始执行 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('knowledge.edit')" @click="router.push('/scenarios')"><span>配置场景知识卡<small>在所属场景内维护输入关联与话术</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" @click="router.push('/runs')"><span>复盘执行记录<small>查看节点轨迹回答与反馈</small></span><ArrowRightOutlined /></button>
</div>
<div class="surface doctrine-panel">
<div class="panel-kicker">平台原则</div>
<blockquote>一句好话术不应只被收藏它应该知道何时出现下一步去哪以及是否真的有效</blockquote>
<div class="flow-note"><span>创建</span><i></i><span>配置</span><i></i><span>执行</span><i></i><span>复盘</span></div>
</div>
</section>
<section class="work-grid">
<div class="surface action-panel">
<div class="panel-kicker">常用入口</div>
<button v-if="auth.can('scenario.edit')" @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段目标和触发条件</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('sop.execute')" @click="router.push('/execute')"><span>开始执行 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('scriptkit.edit')" @click="router.push('/scenarios')"><span>配置话术包<small>在所属场景内维护维度权重话术与联动</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" @click="router.push('/runs')"><span>复盘执行记录<small>查看节点轨迹维度变化回答与反馈</small></span><ArrowRightOutlined /></button>
</div>
<div class="surface doctrine-panel">
<div class="panel-kicker">平台原则</div>
<blockquote>一句好话术不应只被收藏它应该知道何时出现下一步去哪以及是否真的有效</blockquote>
<div class="flow-note"><span>创建</span><i></i><span>配置</span><i></i><span>执行</span><i></i><span>复盘</span></div>
</div>
</section>
<section class="analytics-grid">
<div class="surface analytics-panel">
<div class="panel-kicker">维度权重复盘 · 高频维度值</div>
<a-table :data-source="scriptkit.dimensions.slice(0,10)" :pagination="false" size="small" row-key="value_key" :columns="[{title:'维度',dataIndex:'dim_key',width:110},{title:'维度值',dataIndex:'name'},{title:'覆盖执行',dataIndex:'runs',width:100,align:'right'},{title:'平均权重',dataIndex:'average_weight',width:100,align:'right'}]">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex==='dim_key'"><code>{{ record.dim_key }}</code></template>
<template v-else-if="column.dataIndex==='average_weight'"><b>{{ Number(record.average_weight).toFixed(1) }}</b></template>
</template>
<template #emptyText><span class="panel-empty">执行后自动沉淀维度权重数据</span></template>
</a-table>
</div>
<div class="surface analytics-panel">
<div class="panel-kicker">话术反馈统计</div>
<a-table :data-source="scriptkit.feedback.slice(0,10)" :pagination="false" size="small" row-key="script_key" :columns="[{title:'话术',dataIndex:'name'},{title:'反馈类型',dataIndex:'feedback_type',width:110},{title:'次数',dataIndex:'count',width:80,align:'right'}]">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex==='feedback_type'"><a-tag :color="record.feedback_type==='like'?'green':record.feedback_type==='report'?'orange':'red'">{{ feedbackTypeText(record.feedback_type) }}</a-tag></template>
</template>
<template #emptyText><span class="panel-empty">话术旁的点赞/举报/不合理会汇总到这里</span></template>
</a-table>
</div>
</section>
</a-skeleton>
</div>
</template>
@@ -62,6 +91,8 @@ onMounted(async () => {
.doctrine-panel { background: #202825; border-color: #202825; color: white; }.doctrine-panel .panel-kicker { color: #89a099; }
blockquote { margin: 30px 0 42px; font-family: "Noto Serif SC", serif; font-size: 22px; line-height: 1.65; }
.flow-note { display: flex; align-items: center; color: #9cafaa; font-size: 12px; }.flow-note i { flex: 1; height: 1px; margin: 0 10px; background: #4a5a54; }
@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid { grid-template-columns: 1fr; } }
.analytics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 18px; }
.analytics-panel { padding: 22px; }.analytics-panel code { padding: 2px 5px; color: #17654f; background: #eef5f2; border-radius: 3px; }.panel-empty { display: block; padding: 26px 0; color: var(--muted); font-size: 12px; text-align: center; }
@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid, .analytics-grid { grid-template-columns: 1fr; } }
@media (max-width: 520px) { .metric { min-height: 100px; padding: 16px; }.metric b { font-size: 24px; }.metric-icon, .completion { display: none; } }
</style>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { CheckCircleOutlined, CopyOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined } from '@ant-design/icons-vue'
import { CheckCircleOutlined, CopyOutlined, DislikeOutlined, LikeOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined, WarningOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import type { KnowledgeCollectionStep, PresentationField, PublicFieldView, ScenarioField, SOPNode, SOPRun } from '@/types'
import type { PresentationField, PublicFieldView, ScenarioField, ScriptItem, SOPNode, SOPRun } from '@/types'
interface AvailableSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string }
interface ResultSchema { fields:Array<Record<string,any>> }
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[]; result_schema?:ResultSchema }
const petDoctorDemoInput={
order:{id:'ORDER-DEMO-001',items:[{product_id:'SKU-DEMO-001',product_name:'肠胃护理商品',image_url:'https://placehold.co/160x160/f3f6f4/17654f?text=Pet+Product',symptom_tags:['腹泻']}]},
order:{id:'ORDER-DEMO-001',items:[{product_id:'1301138735118749696',product_name:'拜达尔 阿莫西林克拉维酸钾片 50mg/8片 猫犬专用',image_url:'https://placehold.co/160x160/f3f6f4/17654f?text=Pet+Product'}]},
customer:{name:'王女士'},
pet:{name:'团子',species:'猫'},
}
@@ -25,31 +25,132 @@ const answerForm=ref<FormInstance>()
const feedback=reactive({score:5,comment:''});const feedbackSent=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[]})
const collectionFields=computed(()=>active.value?.node.collection?.context_fields||[])
const collectionSteps=computed(()=>active.value?.node.collection?.steps||[])
const visibleKnowledgeGroups=computed(()=>{const groups=active.value?.node.outputs||[];const root=collectionSteps.value[0];if(!root)return groups;return groups.filter(group=>group.suggested||isSelected(root.field_key,group.name))})
const stage=computed(()=>active.value?.node.stage)
const stageDimensions=computed(()=>stage.value?.dimensions||[])
const stageScripts=computed(()=>stage.value?.scripts||[])
const stageFormFields=computed(()=>stage.value?.form?.fields||[])
const startPresentation=computed(()=>active.value?.node.type==='start'?active.value.node.presentation:undefined)
// 阶段内只改本地草稿,点击“继续下一步”时一次性提交全部作答。
const draftAnswers=reactive<Record<string,string[]>>({})
const draftSelects=reactive<Record<string,boolean>>({})
function initStageDrafts(){
Object.keys(draftAnswers).forEach(key=>delete draftAnswers[key])
Object.keys(draftSelects).forEach(key=>delete draftSelects[key])
const state=(active.value?.run.script_state as any)||{}
const savedAnswers=state.script_answers||{}
const savedSelects=state.dimension_selects||{}
for(const key of Object.keys(savedAnswers))draftAnswers[key]=Array.isArray(savedAnswers[key])?savedAnswers[key]:[]
for(const key of Object.keys(savedSelects))draftSelects[key]=savedSelects[key]===true
}
function hasDraftAnswer(script:ScriptItem){return script.script_key in draftAnswers}
function isDimensionSelected(valueKey:string){return draftSelects[valueKey]===true}
// 症状筛选题与本地作答后的渐进展示。
const screenScript=computed(()=>stageScripts.value.find(item=>item.script_type==='screen'))
const screeningPending=computed(()=>Boolean(stage.value?.screening_required&&screenScript.value&&!hasDraftAnswer(screenScript.value)))
const screenSelectedKeys=computed(()=>new Set(draftAnswers[screenScript.value?.script_key||'']||[]))
function findConfirmScript(valueKey:string){return stageScripts.value.find(item=>item.script_type==='confirm'&&item.dimension_value_key===valueKey)}
function locallyExcluded(valueKey:string){
if(draftSelects[valueKey]===false)return true
if(screenScript.value&&hasDraftAnswer(screenScript.value)&&!screenSelectedKeys.value.has(valueKey))return true
const confirm=findConfirmScript(valueKey)
if(confirm&&(draftAnswers[confirm.script_key]||[]).includes('no'))return true
return false
}
function locallyConfirmed(valueKey:string){
if(draftSelects[valueKey]===true)return true
if(screenScript.value&&hasDraftAnswer(screenScript.value)&&screenSelectedKeys.value.has(valueKey))return true
const confirm=findConfirmScript(valueKey)
if(confirm&&(draftAnswers[confirm.script_key]||[]).includes('yes'))return true
return false
}
function scriptVisible(script:ScriptItem):boolean{
if(script.script_type==='screen')return screeningPending.value
if(screeningPending.value)return false
const key=script.dimension_value_key
if(!key)return true
if(locallyExcluded(key))return false
const weight=script.weight||0
const confirmed=locallyConfirmed(key)||Boolean(script.show_threshold&&weight>=script.show_threshold)
switch(script.script_type){
case 'confirm':return !confirmed&&weight>0&&weight<(script.show_threshold||999)
case 'choice':return confirmed
case 'info':return weight>0&&weight>=(script.show_threshold||1)
case 'message':return confirmed
default:return true
}
}
const visibleStageScripts=computed(()=>stageScripts.value.filter(scriptVisible))
function presentationText(value:unknown){if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return '-';return String(value)}
function itemImage(item:{fields:PresentationField[]}){return item.fields.find(field=>field.kind==='image')}
function itemDetails(item:{fields:PresentationField[]}){return item.fields.filter(field=>field.kind!=='image')}
function hideBrokenImage(event:Event){const image=event.target as HTMLImageElement;image.style.display='none';image.parentElement?.classList.add('no-image')}
async function copyOpening(){const content=startPresentation.value?.opening?.content;if(!content)return;try{await navigator.clipboard.writeText(content);message.success('开场话术已复制')}catch{message.warning('复制失败,请手动选择话术') }}
function collectionOptions(step:KnowledgeCollectionStep){if(!step.from_field)return step.options;const value=answers[step.from_field];const parents=Array.isArray(value)?value:value?[value]:[];return step.options.filter(option=>(option.parents||[]).some(parent=>parents.includes(parent)))}
function collectionBlocked(step:KnowledgeCollectionStep){if(!step.from_field)return false;const value=answers[step.from_field];return !Array.isArray(value)||value.length===0}
function onCollectionChange(step:KnowledgeCollectionStep){let clear=false;for(const item of collectionSteps.value){if(clear){const allowed=new Set(collectionOptions(item).map(option=>option.value));const current=Array.isArray(answers[item.field_key])?answers[item.field_key]:[];answers[item.field_key]=current.filter((value:string)=>allowed.has(value))}if(item.field_key===step.field_key)clear=true}}
async function copyText(content:string){if(!content)return;try{await navigator.clipboard.writeText(content);message.success('话术已复制')}catch{message.warning('复制失败,请手动选择话术') }}
async function recordScriptUsage(script:ScriptItem, scene:string){if(!active.value)return;try{await api.post(`/runs/${active.value.run.id}/script-usage`,{node_key:active.value.node.node_key,script_key:script.script_key,script_title:script.name,template:script.content,scene});message.success('话术已记录')}catch(error){message.error(apiMessage(error))}}
function scriptTypeLabel(type:string){const labels:Record<string,string>={confirm:'确认性提问',info:'信息收集',choice:'选择题',screen:'症状筛选',template:'开场话术',fallback:'兜底话术',message:'提示话术'};return labels[type]||type}
function publicFieldRules(field:PublicFieldView){return field.required?[{required:true,message:`请填写${field.name}`,trigger:['change','blur']}]:[]}
function collectionStepRules(step:KnowledgeCollectionStep){return step.required?[{required:true,type:step.multiple?'array':'string',message:`请选择${step.name}`,trigger:'change'}]:[]}
function collectionStep(index:number){return collectionSteps.value[index]}
function selectedValues(fieldKey:string){return Array.isArray(answers[fieldKey])?answers[fieldKey]:[]}
function isSelected(fieldKey:string,value:string){return selectedValues(fieldKey).includes(value)}
function toggleKnowledge(step:KnowledgeCollectionStep,value:string,checked:boolean){const current=selectedValues(step.field_key);answers[step.field_key]=checked?[...new Set([...current,value])]:current.filter((item:string)=>item!==value);onCollectionChange(step)}
function relationLabel(relation:string){return currentConfig.value.knowledge_selector?.relation_labels?.[relation]||relation}
async function load(){loading.value=true;try{sops.value=(await api.get<{items:AvailableSOP[]}>('/available-sops')).items;const runID=Number(route.query.run||0);if(runID>0){active.value=await api.get<RunView>(`/runs/${runID}`);await prepareNodeAnswers()}else{active.value=null;await clearAnswers()}}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}finally{loading.value=false}}
function selectScriptOption(script:ScriptItem,optionKey:string){if(script.script_type==='choice'||script.script_type==='screen'||script.multiple){toggleChoice(script,optionKey);return}draftAnswers[script.script_key]=[optionKey]}
function optionSelected(script:ScriptItem,optionKey:string){return (draftAnswers[script.script_key]||[]).includes(optionKey)}
function selectedOptionLabels(script:ScriptItem){return (draftAnswers[script.script_key]||[]).map(key=>script.options?.find(option=>option.option_key===key)?.label||key)}
function isScriptAnswered(script:ScriptItem){return hasDraftAnswer(script)}
interface ScriptGroup{key:string;name:string;weight:number;scripts:ScriptItem[];answeredCount:number;total:number}
const scriptGroups=computed(()=>{
const groups:ScriptGroup[]=[]
const flat:ScriptItem[]=[]
const byKey=new Map<string,ScriptGroup>()
for(const script of visibleStageScripts.value){
const key=script.dimension_value_key||''
if(!key){flat.push(script);continue}
let name=key, weight=script.weight||0
for(const dim of stageDimensions.value){const value=dim.values.find(item=>item.value_key===key);if(value){name=value.name;weight=value.weight;break}}
let group=byKey.get(key)
if(!group){group={key,name,weight,scripts:[],answeredCount:0,total:0};byKey.set(key,group);groups.push(group)}
group.scripts.push(script)
}
for(const group of groups){
group.total=group.scripts.length
group.answeredCount=group.scripts.filter(script=>isScriptAnswered(script)).length
}
groups.sort((a,b)=>(b.weight-a.weight)||a.name.localeCompare(b.name,'zh'))
return {groups,flat}
})
const groupPrefs=reactive<Record<string,boolean>>({})
const scriptPrefs=reactive<Record<string,boolean>>({})
function isGroupCollapsed(group:ScriptGroup){const pref=groupPrefs[group.key];if(pref!==undefined)return pref;return group.total>0&&group.answeredCount===group.total}
function toggleGroup(group:ScriptGroup){groupPrefs[group.key]=!isGroupCollapsed(group)}
function isScriptCollapsed(script:ScriptItem){if(!isScriptAnswered(script))return false;const pref=scriptPrefs[script.script_key];return pref!==undefined?pref:true}
function toggleScript(script:ScriptItem){scriptPrefs[script.script_key]=!isScriptCollapsed(script)}
function toggleChoice(script:ScriptItem,optionKey:string){const current=draftAnswers[script.script_key]||[];const next=current.includes(optionKey)?current.filter(key=>key!==optionKey):[...current,optionKey];draftAnswers[script.script_key]=next}
function toggleDimension(valueKey:string){draftSelects[valueKey]=!(draftSelects[valueKey]===true)}
async function scriptFeedback(script:ScriptItem,type:'like'|'report'|'unreasonable'){if(!active.value)return;try{await api.post(`/runs/${active.value.run.id}/script-feedback`,{node_key:active.value.node.node_key,script_key:script.script_key,feedback_type:type});message.success('反馈已记录')}catch(error){message.error(apiMessage(error))}}
const stageFormHint=computed(()=>{if(!stage.value)return'';if(screeningPending.value)return '请先完成症状筛选,再继续作答';if(stage.value.stage_key==='diagnosis'&&!stageHasConfirmedDisease())return '请至少确认一个疾病方向,确认后才能进入药品推荐';if(stage.value.form)return '填写必填信息后,点击“继续下一步”统一提交';return '完成下方作答后,点击“继续下一步”统一提交'})
function stageHasConfirmedDisease(){if(!stage.value||stage.value.stage_key!=='diagnosis')return true;const keys=new Set(stageDimensions.value.flatMap(dim=>dim.values.map(value=>value.value_key)));for(const key of keys){if(locallyConfirmed(key))return true}return false}
async function load(){loading.value=true;try{sops.value=(await api.get<{items:AvailableSOP[]}>('/available-sops')).items;const runID=Number(route.query.run||0);if(runID>0){active.value=await api.get<RunView>(`/runs/${runID}`);await prepareNodeAnswers();initStageDrafts()}else{active.value=null;await clearAnswers()}}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}finally{loading.value=false}}
async function clearAnswers(){Object.keys(answers).forEach(k=>delete answers[k]);await nextTick();answerForm.value?.clearValidate()}
async function prepareNodeAnswers(){await clearAnswers();if(!active.value)return;for(const field of currentFields.value){const value=active.value.run.answers?.[field.field_key];if(value!==undefined&&value!==null)answers[field.field_key]=value}}
async function prepareNodeAnswers(){await clearAnswers();if(!active.value)return;for(const field of currentFields.value){const value=active.value.run.answers?.[field.field_key];if(value!==undefined&&value!==null)answers[field.field_key]=value}for(const field of stageFormFields.value){const value=active.value.run.answers?.[field.key];if(value!==undefined&&value!==null)answers[field.key]=value}}
async function openStart(item:AvailableSOP){selectedSOP.value=item;startForm.external_ref=`business:${Date.now()}`;startForm.input_text=item.scenario_name==='宠物医生问诊问药'?JSON.stringify(petDoctorDemoInput,null,2):'{}';startForm.initial_values={};startOpen.value=true}
async function start(){if(!selectedSOP.value)return;let input:Record<string,any>;try{input=JSON.parse(startForm.input_text)}catch{return message.warning('业务数据 JSON 格式不正确')}submitting.value=true;try{active.value=await api.post<RunView>('/runs',{sop_id:selectedSOP.value.id,input,external_ref:startForm.external_ref,initial_values:startForm.initial_values});startOpen.value=false;await router.replace({query:{run:String(active.value.run.id)}});await prepareNodeAnswers();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false;resultSaved.value=Boolean(active.value.run.final_result)}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
async function next(){if(!active.value)return;try{await answerForm.value?.validate()}catch{return}for(const step of collectionSteps.value){if(step.required&&!selectedValues(step.field_key).length)return message.warning(`请选择${step.name}`)}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});await prepareNodeAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
async function start(){if(!selectedSOP.value)return;let input:Record<string,any>;try{input=JSON.parse(startForm.input_text)}catch{return message.warning('业务数据 JSON 格式不正确')}submitting.value=true;try{active.value=await api.post<RunView>('/runs',{sop_id:selectedSOP.value.id,input,external_ref:startForm.external_ref,initial_values:startForm.initial_values});startOpen.value=false;await router.replace({query:{run:String(active.value.run.id)}});await prepareNodeAnswers();initStageDrafts();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false;resultSaved.value=Boolean(active.value.run.final_result)}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
async function submitStage():Promise<boolean>{
if(!active.value||!stage.value)return false
if(stage.value.form){try{await answerForm.value?.validate()}catch{message.warning('请先完成本阶段必填信息');return false}}
// 拟诊阶段必须至少确认一个疾病方向,否则药品推荐节点没有药物推荐。
if(!stageHasConfirmedDisease()){message.warning('请至少确认一个疾病方向,确认后才能进入药品推荐');return false}
// 只提交当前阶段的话术与维度勾选,避免把上一阶段留下的答案混进来。
const stageScriptKeys=new Set(stageScripts.value.map(item=>item.script_key))
const scriptAnswers=Object.keys(draftAnswers)
.filter(scriptKey=>stageScriptKeys.has(scriptKey))
.map(scriptKey=>({script_key:scriptKey,option_keys:draftAnswers[scriptKey]}))
const stageValueKeys=new Set(stageDimensions.value.flatMap(dim=>dim.values.map(value=>value.value_key)))
const dimensionSelects:Record<string,boolean>={}
for(const [key,selected] of Object.entries(draftSelects)){if(stageValueKeys.has(key))dimensionSelects[key]=selected}
active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,script_answers:scriptAnswers,dimension_selects:dimensionSelects,answers:{...answers}})
active.value=await api.post<RunView>(`/runs/${active.value.run.id}/next`)
return true
}
async function next(){if(!active.value)return;if(active.value.node.type==='stage'){submitting.value=true;try{if(await submitStage()){await prepareNodeAnswers();initStageDrafts();message.success('本阶段回答已统一提交')}}catch(error){message.error(apiMessage(error))}finally{submitting.value=false};return}if(currentFields.value.length){try{await answerForm.value?.validate()}catch{return}}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});await prepareNodeAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
async function back(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/back`);await prepareNodeAnswers();initStageDrafts();message.success('已返回上一步')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function finishManually(){
if(!active.value)return
Modal.confirm({
@@ -68,6 +169,7 @@ function finishManually(){
function inputFor(field:ScenarioField){return field.field_type}
function validationNumber(field:ScenarioField,key:string){const value=field.validation?.[key];return typeof value==='number'?value:undefined}
function fieldRequired(field:ScenarioField){return field.required||(currentConfig.value.required_field_keys||[]).includes(field.field_key)||(['question','choice'].includes(active.value?.node.type||'')&&currentConfig.value.required===true)}
function stageFieldRequired(field:PublicFieldView){return field.required}
function fieldRules(field:ScenarioField){
const rules:any[]=[]
if(fieldRequired(field))rules.push({required:true,message:`请填写${field.field_name}`,trigger:['change','blur']})
@@ -87,13 +189,14 @@ function fieldRules(field:ScenarioField){
async function sendFeedback(){if(!active.value)return;submitting.value=true;try{await api.post(`/runs/${active.value.run.id}/feedback`,feedback);feedbackSent.value=true;message.success('反馈已提交')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
async function saveFinalResult(){if(!active.value)return;let finalResult:Record<string,any>|null;try{finalResult=finalResultText.value.trim()?JSON.parse(finalResultText.value):null}catch{return message.warning('最终结果 JSON 格式不正确')}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/finish`,{result:active.value.run.result||'finish',final_result:finalResult});resultSaved.value=true;message.success('最终结果已保存到本次执行')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
async function reset(){active.value=null;await router.replace({query:{}});await load()}
watch(()=>active.value?.node.node_key,()=>{Object.keys(groupPrefs).forEach(key=>delete groupPrefs[key]);Object.keys(scriptPrefs).forEach(key=>delete scriptPrefs[key]);initStageDrafts()})
watch(()=>route.query.run,()=>load())
onMounted(load)
</script>
<template>
<div class="page-shell execution-shell">
<div class="page-heading"><div><h1>执行话术</h1><p>选择 SOP系统会根据客户回答给出下一步</p></div></div>
<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="openStart(item)"><span class="entry-seq">{{ String(item.id).padStart(2,'0') }}</span><div><small>{{ item.scenario_name }}</small><h2>{{ item.name }}</h2><p>{{ item.description || '按标准步骤执行这套话术流程。' }}</p></div><span class="play"><PlayCircleOutlined /></span></button>
@@ -102,7 +205,7 @@ onMounted(load)
<div v-else class="run-workspace">
<aside class="run-context">
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
<h2>{{ active.run.status==='completed'?'执行已结束':'执行进行中' }}</h2><p>客户回答会自动保存在当前执行记录中</p>
<h2>{{ active.run.status==='completed'?'执行已结束':'执行进行中' }}</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>
@@ -120,20 +223,90 @@ onMounted(load)
<div><span v-for="field in itemDetails(item)" :key="field.key"><small>{{ field.label }}</small><b>{{ presentationText(field.value) }}</b></span></div>
</article>
</div>
<div v-if="startPresentation.opening" class="opening-copy">
<div class="presentation-heading"><b>{{ startPresentation.opening.title }}</b><a-tooltip title="复制话术"><a-button type="text" aria-label="复制开场话术" @click="copyOpening"><CopyOutlined /></a-button></a-tooltip></div>
<p>{{ startPresentation.opening.content }}</p>
</div>
</section>
<section v-if="visibleKnowledgeGroups.length" class="knowledge-groups">
<article v-for="group in visibleKnowledgeGroups" :key="group.key" class="knowledge-group">
<div class="knowledge-head"><a-checkbox v-if="active.node.collection&&collectionStep(0)" :checked="isSelected(collectionStep(0).field_key,group.name)" @change="toggleKnowledge(collectionStep(0),group.name,$event.target.checked)"><b>{{ group.name }}</b></a-checkbox><b v-else>{{ group.name }}</b><span>{{ group.suggested?'系统推断':group.type }}</span></div>
<div v-for="(items,relation) in group.relations" :key="relation" class="relation-block"><small>{{ relationLabel(String(relation)) }}</small><div v-for="item in items" :key="item.key" class="standard-copy selectable-knowledge"><a-checkbox v-if="active.node.collection&&collectionStep(1)?.from_field===collectionStep(0)?.field_key&&currentConfig.knowledge_collection?.steps?.[1]?.relation_type===relation" :checked="isSelected(collectionStep(1).field_key,item.name)" :disabled="!isSelected(collectionStep(0).field_key,group.name)" @change="toggleKnowledge(collectionStep(1),item.name,$event.target.checked)"><b>{{ item.name }}</b></a-checkbox><b v-else>{{ item.name }}</b><p>{{ item.content.template || item.content.standard_copy || item.content.product_name || item.name }}</p><small v-if="item.content.sku_code">SKU{{ item.content.sku_code }}</small></div></div>
</article>
<section v-if="stage" class="stage-panel">
<div class="stage-heading"><b>{{ stage.name }}</b><span>{{ stage.purpose }}</span></div>
<div v-for="dimension in stageDimensions" :key="dimension.dim_key" class="dimension-block">
<div class="dimension-head"><b>{{ dimension.name }}</b><span>点击勾选确认权重越高越靠前</span></div>
<div class="dimension-chips">
<button v-for="value in dimension.values" :key="value.value_key" class="dimension-chip" :class="{hot:value.hot,selected:isDimensionSelected(value.value_key)}" @click="toggleDimension(value.value_key)"><b>{{ value.name }}</b><span class="heat">{{ value.weight }}</span></button>
<span v-if="!dimension.values.length" class="dimension-empty">暂无维度值</span>
</div>
</div>
<div v-if="visibleStageScripts.length" class="stage-scripts">
<div class="stage-heading sub"><b>当前匹配话术</b><span>按症状/疾病分组本地作答结束统一提交</span></div>
<article v-for="script in scriptGroups.flat" :key="script.script_key" class="stage-script" :class="`type-${script.script_type}`">
<div class="script-head"><b>{{ script.name }}</b><span>{{ scriptTypeLabel(script.script_type) }}</span></div>
<p class="script-content">{{ script.content }}</p>
<div v-if="script.options?.length" class="script-options">
<template v-if="script.script_type==='choice'||script.script_type==='screen'">
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="toggleChoice(script,option.option_key)">{{ option.label }}</a-button>
</template>
<template v-else>
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="selectScriptOption(script,option.option_key)">{{ option.label }}</a-button>
</template>
</div>
<p v-if="script.script_type==='choice'||script.script_type==='screen'" class="choice-selection">{{ script.multiple?'可多选':'单选' }}<template v-if="selectedOptionLabels(script).length"> · 已选{{ selectedOptionLabels(script).join('') }}</template></p>
<div class="script-actions">
<a-button size="small" @click="copyText(script.content)"><CopyOutlined />复制</a-button>
<a-button size="small" @click="recordScriptUsage(script,`stage:${stage.stage_key}`)">使用</a-button>
<span class="script-feedback"><a-tooltip title="话术有帮助"><a-button size="small" type="text" @click="scriptFeedback(script,'like')"><LikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不准确"><a-button size="small" type="text" @click="scriptFeedback(script,'report')"><DislikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不合理"><a-button size="small" type="text" @click="scriptFeedback(script,'unreasonable')"><WarningOutlined /></a-button></a-tooltip></span>
</div>
</article>
<section v-for="group in scriptGroups.groups" :key="group.key" class="script-group" :class="{collapsed:isGroupCollapsed(group)}">
<button class="group-head" type="button" @click="toggleGroup(group)">
<span class="group-arrow">{{ isGroupCollapsed(group)?'▸':'▾' }}</span>
<b>{{ group.name }}</b>
<span class="group-meta">{{ group.answeredCount }}/{{ group.total }} 已答 · 权重 {{ group.weight }}</span>
</button>
<div v-if="!isGroupCollapsed(group)" class="group-body">
<article v-for="script in group.scripts" :key="script.script_key" class="stage-script" :class="[`type-${script.script_type}`,{answered:isScriptAnswered(script)}]">
<button v-if="isScriptAnswered(script)" class="answered-fold" type="button" @click="toggleScript(script)">
<span class="group-arrow">{{ isScriptCollapsed(script)?'▸':'▾' }}</span>
<b>{{ script.name }}</b>
<span class="answered-summary">{{ selectedOptionLabels(script).join('、') || '已答' }}</span>
</button>
<template v-if="!isScriptCollapsed(script)">
<div class="script-head"><b>{{ script.name }}</b><span>{{ scriptTypeLabel(script.script_type) }}</span></div>
<p class="script-content">{{ script.content }}</p>
<div v-if="script.products?.length" class="script-products">
<div v-for="product in script.products" :key="product.sku_code" class="product-card"><b>{{ product.product_name }}</b><small>SKU{{ product.sku_code }}</small></div>
</div>
<div v-if="script.options?.length" class="script-options">
<template v-if="script.script_type==='choice'||script.script_type==='screen'">
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="toggleChoice(script,option.option_key)">{{ option.label }}</a-button>
</template>
<template v-else>
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="selectScriptOption(script,option.option_key)">{{ option.label }}</a-button>
</template>
</div>
<p v-if="script.script_type==='choice'||script.script_type==='screen'" class="choice-selection">{{ script.multiple?'可多选':'单选' }}<template v-if="selectedOptionLabels(script).length"> · 已选{{ selectedOptionLabels(script).join('') }}</template></p>
<div class="script-actions">
<a-button size="small" @click="copyText(script.content)"><CopyOutlined />复制</a-button>
<a-button size="small" @click="recordScriptUsage(script,`stage:${stage.stage_key}`)">使用</a-button>
<span class="script-feedback"><a-tooltip title="话术有帮助"><a-button size="small" type="text" @click="scriptFeedback(script,'like')"><LikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不准确"><a-button size="small" type="text" @click="scriptFeedback(script,'report')"><DislikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不合理"><a-button size="small" type="text" @click="scriptFeedback(script,'unreasonable')"><WarningOutlined /></a-button></a-tooltip></span>
</div>
</template>
</article>
</div>
</section>
</div>
<a-form v-if="stage.form" ref="answerForm" :model="answers" layout="vertical" class="answer-form">
<div class="stage-heading sub"><b>{{ stage.name }}信息补充</b><span>填写后点击继续下一步统一提交</span></div>
<a-form-item v-for="field in stageFormFields" :key="field.key" :name="field.key" :label="field.name" :required="stageFieldRequired(field)" :rules="publicFieldRules(field)">
<a-input v-if="field.type==='text'" v-model:value="answers[field.key]" :maxlength="Number(field.validation?.max_length||0)||undefined" />
<a-textarea v-else-if="field.type==='textarea'" v-model:value="answers[field.key]" :rows="3" />
<a-input-number v-else-if="field.type==='number'" v-model:value="answers[field.key]" style="width:100%" />
<a-radio-group v-else-if="field.type==='boolean'" v-model:value="answers[field.key]"><a-radio :value="true">是</a-radio><a-radio :value="false"></a-radio></a-radio-group>
<a-select v-else-if="field.type==='select'" v-model:value="answers[field.key]" :options="(field.options||[]).map((value:unknown)=>({value:String(value),label:String(value)}))" />
<a-input v-else v-model:value="answers[field.key]" />
</a-form-item>
</a-form>
<p class="stage-purpose">本阶段目的{{ stage.purpose }}</p>
</section>
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.run.result==='manual'?'已人工结束':active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.run.result==='manual'?'本次执行由操作人员在当前节点结束。':active.node.content }}</p><section class="result-collector"><div><b>最终结果</b><span>格式由当前场景定义,也可以留空</span></div><pre>{{ JSON.stringify(active.result_schema||{fields:[]},null,2) }}</pre><a-textarea v-model:value="finalResultText" :rows="10" spellcheck="false"/><a-button type="primary" :loading="submitting" @click="saveFinalResult">{{ resultSaved?'更新最终结果':'保存最终结果' }}</a-button></section><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div>
<template v-else>
<a-form ref="answerForm" :model="answers" layout="vertical" class="answer-form">
<a-form v-if="currentFields.length&&!stage" ref="answerForm" :model="answers" layout="vertical" class="answer-form">
<a-form-item v-for="field in currentFields" :key="field.id" :name="field.field_key" :label="field.field_name" :required="fieldRequired(field)" :rules="fieldRules(field)">
<a-radio-group v-if="active.node.type==='choice'" v-model:value="answers[field.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-input v-else-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" :maxlength="validationNumber(field,'max_length')" show-count />
@@ -145,22 +318,8 @@ onMounted(load)
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" value-format="YYYY-MM-DD" style="width:100%" />
<a-input v-else v-model:value="answers[field.field_key]" />
</a-form-item>
<template v-if="active.node.collection">
<div class="collection-heading"><b>{{ active.node.collection.context_title||'补充信息' }}</b><span>{{ active.node.collection.context_hint }}</span></div>
<a-form-item v-for="field in collectionFields" :key="field.key" :name="field.key" :label="field.name" :required="field.required" :rules="publicFieldRules(field)">
<a-input v-if="field.type==='text'" v-model:value="answers[field.key]" />
<a-textarea v-else-if="field.type==='textarea'" v-model:value="answers[field.key]" :rows="3" />
<a-input-number v-else-if="field.type==='number'" v-model:value="answers[field.key]" style="width:100%" />
<a-select v-else-if="field.type==='select'" v-model:value="answers[field.key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
</a-form-item>
<div class="collection-heading selection-summary"><b>{{ active.node.collection.selection_title||'确认选择' }}</b><span>{{ active.node.collection.selection_hint }}</span></div>
<a-form-item v-for="step in collectionSteps" :key="`selector-${step.field_key}`" :name="step.field_key" :label="step.name" :required="step.required" :rules="collectionStepRules(step)">
<a-select v-model:value="answers[step.field_key]" :mode="step.multiple?'multiple':undefined" show-search allow-clear :disabled="collectionBlocked(step)" :placeholder="collectionBlocked(step)?'请先选择上一级':`搜索并选择${step.name}`" :options="collectionOptions(step).map(option=>({value:option.value,label:option.suggested?`${option.label}(系统推断)`:option.label}))" @change="onCollectionChange(step)" />
</a-form-item>
<div class="selected-summary" v-for="step in collectionSteps" :key="step.field_key"><span>{{ step.name }}</span><b>{{ selectedValues(step.field_key).join('、')||'尚未选择' }}</b></div>
</template>
</a-form>
<div class="run-actions"><span>{{ currentFields.length||active.node.collection ? '填写并确认后继续下一步' : '确认当前话术已完成' }}</span><div><a-button danger :disabled="submitting" @click="finishManually"><StopOutlined />人工结束</a-button><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div></div>
<div class="run-actions"><span>{{ stage ? stageFormHint : (currentFields.length?'填写并确认后继续下一步':'确认当前话术已完成') }}</span><div><a-button v-if="active.node.type!=='start'" :disabled="submitting" @click="back">上一步</a-button><a-button danger :disabled="submitting" @click="finishManually"><StopOutlined />人工结束</a-button><a-button type="primary" size="large" :disabled="submitting||(stage&&stage.stage_key==='diagnosis'&&!stageHasConfirmedDisease())" :loading="submitting" @click="next">继续下一步</a-button></div></div>
</template>
</main>
</div>
@@ -172,12 +331,12 @@ onMounted(load)
</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}.run-actions>div{display:flex;align-items:center;gap:9px}.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)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
.knowledge-head{display:flex;align-items:center;justify-content:space-between;padding:12px 0}.knowledge-head span{color:var(--muted);font-size:11px}.standard-copy{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small{font-size:10px;font-weight:750}.standard-copy p{margin:6px 0 0;line-height:1.7}
.knowledge-groups{display:grid;gap:14px;margin-bottom:24px}.knowledge-group{padding:0 16px 16px;border:1px solid var(--line);border-left:3px solid var(--green);background:#fff}.relation-block{margin-top:10px}.relation-block>small{display:block;margin-bottom:6px;color:var(--muted);font-weight:700}.relation-block .standard-copy{margin-top:6px}.relation-block .standard-copy b{font-size:12px}
.start-presentation{display:grid;gap:22px;margin-bottom:28px}.start-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.start-summary>div{min-width:0;display:flex;flex-direction:column;gap:5px;padding:14px 16px}.start-summary>div:nth-child(odd){border-right:1px solid var(--line)}.start-summary small,.order-product small{color:var(--muted);font-size:10px}.start-summary b,.order-product b{overflow-wrap:anywhere;font-size:13px}.presentation-heading{min-height:36px;display:flex;align-items:center;justify-content:space-between;gap:12px}.presentation-heading>span{color:var(--muted);font-size:11px}.order-products{display:grid;gap:8px}.order-product{min-height:82px;display:grid;grid-template-columns:72px minmax(0,1fr);gap:14px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)}.order-product.no-image{grid-template-columns:1fr}.order-product img{width:72px;height:72px;object-fit:cover;border:1px solid var(--line);border-radius:4px;background:#f4f6f5}.order-product>div{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(100px,1fr);gap:12px}.order-product span{min-width:0;display:flex;flex-direction:column;gap:5px}.opening-copy{padding:16px 18px;border-left:3px solid var(--green);background:#edf6f2}.opening-copy .presentation-heading{min-height:28px}.opening-copy p{margin:9px 0 0;color:#21302a;font-size:16px;line-height:1.8}.opening-copy :deep(.ant-btn){color:var(--green)}
.collection-heading{display:flex;justify-content:space-between;gap:16px;margin:26px 0 16px;padding-bottom:10px;border-bottom:1px solid var(--line)}.collection-heading span{color:var(--muted);font-size:11px}
.selectable-knowledge :deep(.ant-checkbox-wrapper){display:flex;align-items:center}.selected-summary{display:grid;grid-template-columns:130px 1fr;gap:12px;padding:9px 0;border-bottom:1px solid var(--line)}.selected-summary span{color:var(--muted);font-size:12px}.selected-summary b{font-size:13px}.selection-summary{margin-top:20px}
.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:13px;line-height:1.7}
.stage-panel{display:grid;gap:20px;margin-bottom:26px}.stage-heading{display:flex;align-items:baseline;justify-content:space-between;gap:14px;padding-bottom:10px;border-bottom:1px solid var(--line)}.stage-heading b{font-size:15px}.stage-heading span{color:var(--muted);font-size:11px}.stage-heading.sub{margin-top:8px}
.dimension-block{padding:14px 16px;border:1px solid var(--line);background:#fbfdfc}.dimension-head{display:flex;justify-content:space-between;margin-bottom:10px}.dimension-head b{font-size:13px}.dimension-head span{color:var(--muted);font-size:11px}.dimension-chips{display:flex;flex-wrap:wrap;gap:8px}.dimension-chip{display:inline-flex;align-items:center;gap:7px;padding:6px 11px;font-size:12px;color:#4a5a53;background:#fff;border:1px solid var(--line);border-radius:20px;cursor:pointer}.dimension-chip .heat{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 4px;color:#9aa8a1;background:#eef2f0;border-radius:10px;font-size:10px}.dimension-chip.hot{color:#fff;background:#287a60;border-color:#287a60}.dimension-chip.hot .heat{color:#287a60;background:#fff}.dimension-chip.selected{outline:2px solid #d08736;outline-offset:1px}.dimension-empty{color:var(--muted);font-size:12px}
.stage-scripts{display:grid;gap:12px}.script-group{border:1px solid var(--line);border-left:3px solid var(--green);background:#fff;overflow:hidden}.script-group.collapsed{border-left-color:#c8d4ce;background:#fbfcfc}.group-head{width:100%;display:flex;align-items:center;gap:10px;padding:12px 14px;text-align:left;background:transparent;border:0;cursor:pointer}.group-head:hover{background:#f4f8f6}.group-arrow{color:var(--green);font-size:11px}.group-head b{font-size:14px}.group-meta{margin-left:auto;color:var(--muted);font-size:11px}.group-body{display:grid;gap:10px;padding:2px 12px 12px;border-top:1px solid #eef2f0}.stage-script{padding:14px 16px;border:1px solid var(--line);border-left:3px solid var(--green);background:#fff}.stage-script.type-confirm{border-left-color:#d08736;background:#fffdf7}.stage-script.type-fallback{border-left-color:#8a97a0;background:#f6f8f8}.stage-script.type-template{border-left-color:#4f7fae;background:#f5f9fd}.stage-script.answered{border-left-color:#b9c9c2;background:#fafcfb}.answered-fold{width:100%;display:flex;align-items:center;gap:10px;padding:4px 2px;text-align:left;background:transparent;border:0;cursor:pointer}.answered-fold:hover{border-radius:4px;background:#f0f5f3}.answered-fold b{font-size:13px}.answered-summary{margin-left:auto;max-width:60%;overflow:hidden;color:#6d7b74;font-size:11px;text-overflow:ellipsis;white-space:nowrap}.script-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.script-head b{font-size:13px}.script-head span{color:var(--muted);font-size:10px}.script-head .dim-key{font-style:normal;margin-left:8px}.script-content{margin:10px 0 0;color:#24312b;font-size:14px;line-height:1.8;white-space:pre-wrap}.script-options{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px}.choice-selection{margin:8px 0 0;color:var(--muted);font-size:11px}.script-products{margin-top:12px;display:grid;gap:8px}.product-card{padding:10px 12px;background:#f2f7f5;border-left:3px solid var(--green);border-radius:4px}.product-card b{font-size:13px}.product-card small{margin-left:10px;color:var(--muted);font-size:11px}.script-actions{display:flex;gap:8px;align-items:center;margin-top:12px}.script-feedback{margin-left:auto;display:flex;gap:2px}.stage-purpose{margin:0;padding-top:14px;color:var(--muted);font-size:11px;border-top:1px dashed var(--line)}
.start-presentation{display:grid;gap:22px;margin-bottom:28px}.start-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.start-summary>div{min-width:0;display:flex;flex-direction:column;gap:5px;padding:14px 16px}.start-summary>div:nth-child(odd){border-right:1px solid var(--line)}.start-summary small,.order-product small{color:var(--muted);font-size:10px}.start-summary b,.order-product b{overflow-wrap:anywhere;font-size:13px}.presentation-heading{min-height:36px;display:flex;align-items:center;justify-content:space-between;gap:12px}.presentation-heading>span{color:var(--muted);font-size:11px}.order-products{display:grid;gap:8px}.order-product{min-height:82px;display:grid;grid-template-columns:72px minmax(0,1fr);gap:14px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)}.order-product.no-image{grid-template-columns:1fr}.order-product img{width:72px;height:72px;object-fit:cover;border:1px solid var(--line);border-radius:4px;background:#f4f6f5}.order-product>div{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(100px,1fr);gap:12px}.order-product span{min-width:0;display:flex;flex-direction:column;gap:5px}
.json-input,.result-collector :deep(textarea){font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.result-collector{max-width:700px;margin:0 auto 22px;padding:18px;text-align:left;background:#f5f8f6;border:1px solid var(--line)}.result-collector>div{display:flex;justify-content:space-between;margin-bottom:10px}.result-collector>div span{color:var(--muted);font-size:11px}.result-collector pre{max-height:160px;overflow:auto;padding:10px;background:#202825;color:#dbe8e3;font-size:11px;white-space:pre-wrap}.result-collector button{margin-top:10px}
.answer-form{margin-top:20px}
@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}.quick-feedback{grid-template-columns:1fr}.quick-feedback :deep(.ant-input){grid-column:auto}.start-summary{grid-template-columns:1fr}.start-summary>div:nth-child(odd){border-right:0}.start-summary>div+div{border-top:1px solid var(--line)}.order-product>div{grid-template-columns:1fr}}
</style>

View File

@@ -9,7 +9,11 @@ import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ username: 'admin', password: 'admin123' })
const isDevelopment = import.meta.env.DEV
const form = reactive({
username: isDevelopment ? 'admin' : '',
password: isDevelopment ? 'admin123' : ''
})
async function submit() {
loading.value = true
@@ -53,7 +57,7 @@ async function submit() {
</a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">进入平台</a-button>
</a-form>
<div class="login-note"><span></span>本地开发账号已预填</div>
<div v-if="isDevelopment" class="login-note"><span></span>本地开发账号已预填</div>
</div>
</section>
</main>

View File

@@ -1,22 +1,25 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { ArrowLeftOutlined, CheckCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { RunDetail, RunEvent, RunFeedback, ScenarioField } from '@/types'
import type { RunDetail, RunDimension, RunEvent, RunFeedback, ScenarioField, ScriptFeedback } from '@/types'
interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];fields:ScenarioField[]}
interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];fields:ScenarioField[];dimensions:RunDimension[];script_feedback:ScriptFeedback[]}
const route=useRoute();const router=useRouter();const auth=useAuthStore();const loading=ref(true);const detail=ref<DetailResponse|null>(null);const submitting=ref(false);const feedback=reactive({score:5,comment:''})
const fieldMap=computed(()=>new Map((detail.value?.fields||[]).map(field=>[field.field_key,field.field_name])))
const answers=computed(()=>Object.entries(detail.value?.run.answers||{}))
const finalResult=computed(()=>detail.value?.run.final_result||null)
const dimensionWeights=computed(()=>(detail.value?.dimensions||[]).filter(item=>item.weight>0).sort((a,b)=>b.weight-a.weight))
const feedbackTypeText=computed(()=>({like:'有帮助',report:'不准确',unreasonable:'不合理'} as Record<string,string>))
const canFeedback=computed(()=>auth.can('runs.feedback')&&detail.value?.run.status==='completed'&&!detail.value.feedback.some(item=>item.user_id===auth.user?.user_id))
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'}
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',finish:'结束执行',final_result:'保存最终结果'}[v]||v}
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',next:'进入下一步',back:'返回上一步',script_used:'使用话术',script_feedback:'话术反馈',reset:'重置执行',finish:'结束执行',final_result:'保存最终结果'}[v]||v}
function valueText(value:any){if(value===true)return'是';if(value===false)return'否';if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return'-';return String(value)}
function answerEntries(event:RunEvent){const values=event.payload?.answers||{};return Object.entries(values)}
function eventExtra(event:RunEvent){if(event.action==='answer'&&event.payload?.script_key){return `话术 ${event.payload.script_key} · 选项 ${(event.payload.option_keys||[]).join('、')||'-'}`}if(event.action==='answer'&&event.payload?.dimension_value_key){return `勾选维度值 ${event.payload.dimension_value_key}=${event.payload.selected}`}if(event.action==='script_feedback'){return `话术 ${event.payload.script_key} · ${feedbackTypeText.value[event.payload.feedback_type]||event.payload.feedback_type}`}return ''}
async function load(){loading.value=true;try{detail.value=await api.get<DetailResponse>(`/runs/${route.params.id}/detail`)}catch(error){message.error(apiMessage(error));router.replace('/runs')}finally{loading.value=false}}
async function submitFeedback(){submitting.value=true;try{await api.post(`/runs/${route.params.id}/feedback`,feedback);message.success('反馈已提交');await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
onMounted(load)
@@ -36,10 +39,10 @@ onMounted(load)
<main class="surface timeline-panel">
<div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div>
<a-timeline class="run-timeline">
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':event.action==='answer'?'blue':'green'">
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':['answer','next','back'].includes(event.action)?'blue':'green'">
<div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div>
<p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p>
<div v-for="group in event.outputs||[]" :key="group.key" class="event-knowledge"><div><b>{{ group.name }}</b><small>{{ group.type }}</small></div><div v-for="(items,relation) in group.relations" :key="relation"><p><b>{{ relation }}</b></p><p v-for="item in items" :key="item.key">{{ item.content.template || item.content.standard_copy || item.name }}</p></div></div>
<p v-if="eventExtra(event)" class="event-extra">{{ eventExtra(event) }}</p>
<div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div>
</a-timeline-item>
</a-timeline>
@@ -47,8 +50,10 @@ onMounted(load)
<aside class="detail-aside">
<section class="surface summary-panel"><div class="section-title"><span>执行信息</span></div><dl><div><dt>状态</dt><dd><a-tag :color="detail.run.status==='completed'?'green':'orange'">{{ detail.run.status==='completed'?'已完成':'进行中' }}</a-tag></dd></div><div><dt>开始时间</dt><dd>{{ new Date(detail.run.started_at).toLocaleString('zh-CN') }}</dd></div><div><dt>完成时间</dt><dd>{{ detail.run.completed_at?new Date(detail.run.completed_at).toLocaleString('zh-CN'):'-' }}</dd></div><div><dt>当前节点</dt><dd>{{ detail.run.current_node_key }}</dd></div></dl></section>
<section class="surface answer-panel"><div class="section-title"><span>维度权重</span><small>{{ dimensionWeights.length }} 个有效值</small></div><div v-if="dimensionWeights.length" class="answer-list"><div v-for="item in dimensionWeights" :key="`${item.dimension_id}-${item.value_key}`"><span>{{ item.value_key }}</span><b>{{ item.weight }}</b></div></div><div v-else class="aside-empty">暂无有效维度</div></section>
<section class="surface answer-panel"><div class="section-title"><span>已采集信息</span><small>{{ answers.length }} </small></div><div v-if="answers.length" class="answer-list"><div v-for="[key,value] in answers" :key="key"><span>{{ fieldMap.get(key)||key }}</span><b>{{ valueText(value) }}</b></div></div><div v-else class="aside-empty">尚未采集字段</div></section>
<section class="surface answer-panel"><div class="section-title"><span>最终结果</span></div><pre v-if="finalResult" class="final-result">{{ JSON.stringify(finalResult,null,2) }}</pre><div v-else class="aside-empty">本次执行未收集最终结果</div></section>
<section v-if="detail.script_feedback.length" class="surface answer-panel"><div class="section-title"><span>话术反馈</span><small>{{ detail.script_feedback.length }} 条</small></div><div class="answer-list"><div v-for="item in detail.script_feedback" :key="item.id"><span>话术 #{{ item.script_id }}</span><b>{{ feedbackTypeText[item.feedback_type]||item.feedback_type }}</b></div></div></section>
</aside>
</section>
@@ -64,7 +69,5 @@ onMounted(load)
</template>
<style scoped>
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;text-align:right;font-size:12px}.answer-list>div{display:flex;flex-direction:column;padding:10px 0;border-bottom:1px solid #edf0ee}.answer-list>div:last-child{border-bottom:0}.answer-list span{color:var(--muted);font-size:11px}.answer-list b{margin-top:4px;font-size:13px}.aside-empty{padding:24px 0;color:#929d98;text-align:center;font-size:12px}.feedback-band{margin-top:16px;padding:22px 24px}.feedback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feedback-list article{padding:14px 0;border-top:1px solid var(--line)}.feedback-list article>div{display:flex;align-items:center;justify-content:space-between}.feedback-list :deep(.ant-rate){font-size:15px}.feedback-list p{margin:9px 0;color:#54615b}.feedback-list time{color:#929d98;font-size:11px}.feedback-form{display:grid;grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;border-top:1px solid var(--line)}.feedback-form>div{display:flex;flex-direction:column;gap:6px}.feedback-form>div>span{font-size:12px;font-weight:650}.feedback-form :deep(.ant-rate){font-size:20px}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-aside{display:grid;grid-template-columns:1fr 1fr}.feedback-form{grid-template-columns:1fr}.feedback-list{grid-template-columns:1fr}}@media(max-width:620px){.detail-heading{align-items:flex-start;flex-direction:column}.result-block{width:100%}.detail-aside{grid-template-columns:1fr}.timeline-panel{padding:18px}.event-head{align-items:flex-start;flex-wrap:wrap}.event-head time{width:100%;margin:0}.event-answers{grid-template-columns:1fr}}
.event-knowledge{margin-top:10px;padding:12px 14px;background:#f2f7f5;border-left:3px solid var(--green)}.event-knowledge>div{display:flex;justify-content:space-between;gap:12px}.event-knowledge small{color:var(--muted)}.event-knowledge .risk-note{padding-top:8px;border-top:1px solid #dce9e4;color:#8d5b16}
.final-result{max-height:280px;overflow:auto;margin:0;padding:12px;color:#33413b;background:#f4f7f5;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-extra{margin-top:6px;padding:7px 10px;color:#7c5a20;background:#fbf2e3;border-left:2px solid #c08026;font-size:12px}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;font-size:12px;text-align:right}.answer-list{display:grid;gap:8px}.answer-list>div{display:flex;justify-content:space-between;gap:12px;padding:9px 11px;background:#f4f7f5;border-left:2px solid #a6cbbd}.answer-list span{min-width:0;color:var(--muted);font-size:12px;overflow-wrap:anywhere}.answer-list b{font-size:12px;text-align:right;overflow-wrap:anywhere}.aside-empty{padding:20px 0;color:#98a39e;font-size:12px;text-align:center}.feedback-band{padding:22px 24px}.feedback-list{display:grid;gap:10px}.feedback-list article{padding:12px 14px;background:#f6f8f7;border-left:3px solid var(--green)}.feedback-list article>div{display:flex;justify-content:space-between}.feedback-list p{margin:8px 0 4px;color:#4c5a54}.feedback-list time{color:#96a19c;font-size:11px}.feedback-form{display:grid;gap:10px;max-width:560px}.feedback-form>div{display:flex;align-items:center;gap:14px}.feedback-form .ant-btn{width:130px;justify-self:end}.final-result{max-height:280px;overflow:auto;margin:0;padding:12px;color:#33413b;background:#f4f7f5;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}}
</style>

View File

@@ -23,7 +23,7 @@ const formRequiredFieldOptions = computed(() => {
return fields.value.filter(field=>keys.includes(field.field_key)).map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))
})
const canEdit = computed(() => auth.can('sop.edit'))
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 nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',label:'信息表单'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'stage',label:'话术阶段'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
interface ConditionRuleDraft { field:string; operator:string; value:string }
const newConditionRule = ():ConditionRuleDraft => ({ field:'', operator:'equals', value:'' })
const edgeDraft = reactive({ target_node_key: '', conditional: false, mode: 'all', rules: [newConditionRule()] as ConditionRuleDraft[] })
@@ -32,7 +32,7 @@ async function load() {
loading.value = true
try {
const data = await api.get<{ sop:SOP; edit:{start_node_key:string}; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
sop.value=data.sop; graph.value=data.edit; nodes.value=data.nodes.map(n=>({...n,config:{...(n.config||{}),...(n.type==='knowledge'?{knowledge_selector:n.config?.knowledge_selector||{derived_field:'',knowledge_types:[],knowledge_keys:[],relation_types:[]}}:{}),...(n.type==='start'?{presentation:n.config?.presentation||{summary_field_keys:[],item_field_keys:[],image_field_keys:[],opening_title:'开场话术',opening_template:''}}:{}),...(n.type==='form'?{required_field_keys:n.config?.required_field_keys||[]}:{})}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value = nodes.value[0]?.node_key || ''
sop.value=data.sop; graph.value=data.edit; nodes.value=data.nodes.map(n=>({...n,config:{...(n.config||{}),...(n.type==='stage'?{stage_key:n.config?.stage_key||''}:{}),...(n.type==='start'?{presentation:n.config?.presentation||{summary_field_keys:[],item_field_keys:[],image_field_keys:[],opening_title:'开场话术',opening_template:''}}:{}),...(n.type==='form'?{required_field_keys:n.config?.required_field_keys||[]}:{})}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value = nodes.value[0]?.node_key || ''
const scenarioData = await api.get<{ fields:ScenarioField[] }>(`/scenarios/${data.sop.scenario_id}`)
fields.value=scenarioData.fields
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
@@ -66,7 +66,6 @@ function addConditionRule(){edgeDraft.rules.push(newConditionRule())}
function removeConditionRule(index:number){if(edgeDraft.rules.length>1)edgeDraft.rules.splice(index,1)}
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 csvValues(value:unknown){return Array.isArray(value)?value.join(', '):''}
function setSelectorList(key:string,value:string){if(!selected.value)return;selected.value.config.knowledge_selector ||= {};selected.value.config.knowledge_selector[key]=value.split(',').map(item=>item.trim()).filter(Boolean)}
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:'小于',in:'属于列表',exists:'已填写',not_exists:'未填写'};const fieldName=fields.value.find(field=>field.field_key===condition.field)?.field_name||condition.field;const value=Array.isArray(condition.value)?condition.value.join('、'):(condition.value??'');return `${fieldName} ${names[condition.operator]||condition.operator} ${value}`}
async function save():Promise<boolean>{saving.value=true;try{await api.put(`/sops/${id}/graph`,{start_node_key:graph.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('SOP 已保存并生效');await load();return true}catch(error){message.error(apiMessage(error));return false}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))}}
@@ -100,10 +99,10 @@ onMounted(load)
<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="!canEdit" 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="!canEdit" /></a-form-item></div></template>
<template v-if="selected.type==='form'"><a-form-item label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" 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-select v-model:value="selected.config.required_field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择必须在本节点完成的字段" :options="formRequiredFieldOptions" /></a-form-item></template>
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!canEdit" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
<template v-if="selected.type==='knowledge'">
<a-form-item label="派生知识字段"><a-input v-model:value="selected.config.knowledge_selector.derived_field" :disabled="!canEdit" placeholder="例如 matched_symptoms值为知识 key 数组" /></a-form-item>
<div class="property-grid"><a-form-item label="根知识类型"><a-input :value="csvValues(selected.config.knowledge_selector.knowledge_types)" :disabled="!canEdit" placeholder="symptom" @change="setSelectorList('knowledge_types',($event.target as HTMLInputElement).value)" /></a-form-item><a-form-item label="固定知识 key"><a-input :value="csvValues(selected.config.knowledge_selector.knowledge_keys)" :disabled="!canEdit" placeholder="多个 key 用逗号分隔" @change="setSelectorList('knowledge_keys',($event.target as HTMLInputElement).value)" /></a-form-item></div>
<a-form-item label="允许返回的关系类型"><a-input :value="csvValues(selected.config.knowledge_selector.relation_types)" :disabled="!canEdit" placeholder="recommended_copy, possible_disease" @change="setSelectorList('relation_types',($event.target as HTMLInputElement).value)" /></a-form-item>
<template v-if="selected.type==='stage'">
<a-form-item label="话术包阶段标识"><a-input v-model:value="selected.config.stage_key" :disabled="!canEdit" placeholder="例如 opening、symptom、diagnosis、recommend" /></a-form-item>
<a-form-item label="内容补充表单字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" 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-select v-model:value="selected.config.required_field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="必须填写完成后才能进入下一步" :options="formRequiredFieldOptions" /></a-form-item>
</template>
</a-form>
<div class="transition-head"><div><b>下一步路径</b><span>条件路径优先匹配默认路径始终排在最后</span></div></div>

View File

@@ -6,7 +6,7 @@ import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { Scenario, ScenarioField, SOP } from '@/types'
import ScenarioKnowledgeTab from '@/components/ScenarioKnowledgeTab.vue'
import ScriptPackageTab from '@/components/ScriptPackageTab.vue'
const route = useRoute()
const router = useRouter()
@@ -26,7 +26,7 @@ const scenarioForm = reactive({ name: '', industry: '', role_name: '', goal: '',
const fieldForm = reactive({ field_key: '', field_name: '', source_path: '', field_type: 'text', required: false, options_text: '', min: undefined as number | undefined, max: undefined as number | undefined, min_length: undefined as number | undefined, max_length: undefined as number | undefined })
const sopForm = reactive({ name: '', description: '' })
const contractText = ref('{\n "output_schema": {"fields": []},\n "result_schema": {"fields": []},\n "rules": [],\n "allowed_origins": []\n}')
const previewText = ref('{\n "input": {},\n "initial_values": {},\n "knowledge_selector": {\n "derived_field": "",\n "knowledge_types": [],\n "knowledge_keys": [],\n "relation_types": []\n }\n}')
const previewText = ref('{\n "input": {},\n "initial_values": {},\n "stage_key": "opening"\n}')
const previewResult = ref<any>(null)
const previewing = ref(false)
const statusColor = computed(() => scenario.value?.status === 'active' ? 'green' : 'default')
@@ -212,14 +212,14 @@ onMounted(()=>{load();loadContract()})
<div v-else class="empty-state"><BranchesOutlined /><p>还没有 SOP创建一套流程将经验变成连续动作</p></div>
</section>
</a-tab-pane>
<a-tab-pane v-if="auth.can('knowledge.view')" key="knowledge" tab="知识卡">
<ScenarioKnowledgeTab :scenario-id="scenarioId" :fields="fields" :editable="auth.can('knowledge.edit') && auth.can('scenario.edit')" />
<a-tab-pane v-if="auth.can('scriptkit.view')" key="scriptkit" tab="话术包">
<ScriptPackageTab :scenario-id="scenarioId" :editable="auth.can('scriptkit.edit') && auth.can('scenario.edit')" />
</a-tab-pane>
<a-tab-pane key="contract" tab="规则与输出">
<section class="surface contract-editor"><div class="toolbar"><div><b>场景运行契约</b><span class="toolbar-note">定义派生规则过程输出最终结果格式和允许接入的域名</span></div><a-button v-if="auth.can('scenario.edit')" type="primary" :loading="saving" @click="saveContract"><SaveOutlined />保存契约</a-button></div><div class="sdk-identifiers"><span>scenarioKey <code>{{ scenario?.scenario_key }}</code></span><span>publicKey <code>{{ scenario?.public_key }}</code></span></div><a-textarea v-model:value="contractText" :disabled="!auth.can('scenario.edit')" class="contract-json" :auto-size="{minRows:20,maxRows:36}" spellcheck="false" /></section>
</a-tab-pane>
<a-tab-pane key="preview" tab="预览">
<section class="preview-layout"><div class="surface preview-input"><div class="toolbar"><div><b>样例业务数据</b><span class="toolbar-note">使用真实映射规则知识和输出服务运行</span></div><a-button type="primary" :loading="previewing" @click="previewScenario">运行预览</a-button></div><a-textarea v-model:value="previewText" class="contract-json" :auto-size="{minRows:24,maxRows:40}" spellcheck="false" /></div><div class="surface preview-output"><div class="toolbar"><div><b>运行结果</b><span class="toolbar-note">输入映射派生事实命中规则知识和输出</span></div></div><pre v-if="previewResult">{{ JSON.stringify(previewResult,null,2) }}</pre><a-empty v-else description="运行预览后查看结果" /></div></section>
<section class="preview-layout"><div class="surface preview-input"><div class="toolbar"><div><b>样例业务数据</b><span class="toolbar-note">使用真实映射规则话术包和输出服务运行</span></div><a-button type="primary" :loading="previewing" @click="previewScenario">运行预览</a-button></div><a-textarea v-model:value="previewText" class="contract-json" :auto-size="{minRows:24,maxRows:40}" spellcheck="false" /></div><div class="surface preview-output"><div class="toolbar"><div><b>运行结果</b><span class="toolbar-note">输入映射派生事实命中规则阶段话术和输出</span></div></div><pre v-if="previewResult">{{ JSON.stringify(previewResult,null,2) }}</pre><a-empty v-else description="运行预览后查看结果" /></div></section>
</a-tab-pane>
</a-tabs>
</a-skeleton>