feat: simplify SOP to immediate-effect configuration

This commit is contained in:
Eric 1549169735@qq.com
2026-08-18 16:27:02 +08:00
parent c5ab886b70
commit 8de48fb05e
150 changed files with 6764 additions and 1626 deletions

View File

@@ -0,0 +1,29 @@
<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

@@ -4,8 +4,6 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
AppstoreOutlined,
AuditOutlined,
BookOutlined,
DashboardOutlined,
HistoryOutlined,
LogoutOutlined,
@@ -25,22 +23,20 @@ const selectedKeys = computed(() => {
if (route.path.startsWith('/scenarios') || route.path.startsWith('/sops')) return ['scenarios']
if (route.path.startsWith('/execute')) return ['execute']
if (route.path.startsWith('/runs')) return ['runs']
if (route.path.startsWith('/reviews')) return ['reviews']
if (route.path.startsWith('/members')) return ['members']
if (route.path.startsWith('/knowledge')) return ['knowledge']
return ['dashboard']
})
const titles: Record<string, string> = {
dashboard: '工作台', scenarios: '场景与 SOP', reviews: '审核中心', execute: '执行话术', runs: '执行记录', knowledge: '知识卡', members: '企业成员',
dashboard: '工作台', scenarios: '场景与 SOP', execute: '执行话术', runs: '执行记录', members: '企业成员',
}
const pageTitle = computed(() => titles[selectedKeys.value[0]] || '销冠 SOP')
const roleText = computed(() => ({admin:'企业管理员',editor:'SOP 编辑者',reviewer:'审核者',operator:'一线执行者'}[auth.user?.role_code||'']||auth.user?.role_code||''))
const roleText = computed(() => ({admin:'企业管理员',editor:'SOP 编辑者',operator:'一线执行者'}[auth.user?.role_code||'']||auth.user?.role_code||''))
onMounted(() => auth.loadUser().catch(() => auth.logout()))
function navigate({ key }: { key: string }) {
const target: Record<string, string> = { dashboard: '/', scenarios: '/scenarios', reviews: '/reviews', execute: '/execute', runs: '/runs', knowledge: '/knowledge', members: '/members' }
const target: Record<string, string> = { dashboard: '/', scenarios: '/scenarios', execute: '/execute', runs: '/runs', members: '/members' }
mobileOpen.value = false
router.push(target[key])
}
@@ -64,10 +60,8 @@ function logout() {
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">
<a-menu-item key="dashboard"><DashboardOutlined /><span>工作台</span></a-menu-item>
<a-menu-item v-if="auth.can('scenario.view')" key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.review')" key="reviews"><AuditOutlined /><span>审核中心</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.execute')" key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
<a-menu-item v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
<a-menu-item v-if="auth.can('knowledge.view')" key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
<a-menu-item v-if="auth.can('member.manage')" key="members"><TeamOutlined /><span>企业成员</span></a-menu-item>
</a-menu>
<div class="sider-foot" :class="{ compact: collapsed }">
@@ -109,10 +103,8 @@ function logout() {
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">
<a-menu-item key="dashboard"><DashboardOutlined /><span>工作台</span></a-menu-item>
<a-menu-item v-if="auth.can('scenario.view')" key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.review')" key="reviews"><AuditOutlined /><span>审核中心</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.execute')" key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
<a-menu-item v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
<a-menu-item v-if="auth.can('knowledge.view')" key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
<a-menu-item v-if="auth.can('member.manage')" key="members"><TeamOutlined /><span>企业成员</span></a-menu-item>
</a-menu>
</a-drawer>

View File

@@ -16,9 +16,7 @@ const router = createRouter({
{ path: 'execute', name: 'execute', component: () => import('@/views/ExecuteView.vue') },
{ path: 'runs', name: 'runs', component: () => import('@/views/RunHistoryView.vue') },
{ path: 'runs/:id', name: 'run-detail', component: () => import('@/views/RunDetailView.vue') },
{ path: 'reviews', name: 'reviews', component: () => import('@/views/ReviewCenterView.vue') },
{ path: 'members', name: 'members', component: () => import('@/views/MembersView.vue') },
{ path: 'knowledge', name: 'knowledge', component: () => import('@/views/KnowledgeView.vue') },
{ path: 'members', name: 'members', component: () => import('@/views/MembersView.vue') },
],
},
],

View File

@@ -1,18 +1,23 @@
export interface BaseEntity { id: number; created_at: string; updated_at: string }
export interface User { user_id: number; tenant_id: number; role_code: string; permissions: string[]; username: string; display_name: string }
export interface Scenario extends BaseEntity { tenant_id: number; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number }
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
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 }
export interface SOPVersion extends BaseEntity { sop_id: number; version: number; status: string; start_node_key: string; published_at?: string; created_by?:number; reviewed_by?:number; creator_name?:string; reviewer_name?:string }
export interface KnowledgeSnapshot { card_id:number; card_version_id:number; version:number; title:string; standard_copy:string; forbidden_copy:string; risk_note: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; knowledge?:KnowledgeSnapshot }
export interface KnowledgeCopy { id:string; title:string; content:string }
export interface KnowledgeSnapshot { card_id:number; card_version_id:number; version:number; title:string; standard_copy:string; forbidden_copy:string; risk_note:string; copies:KnowledgeCopy[] }
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 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; knowledge?:KnowledgeSnapshot; outputs?:KnowledgeGroup[]; collection?:KnowledgeCollection }
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; sop_version_id: number; operator_id:number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; version?:number; operator_name?:string }
export interface KnowledgeCard extends BaseEntity { scenario_id: number; title: string; status: string; version?: number; content?: Record<string, any> }
export interface KnowledgeCardVersion extends BaseEntity { knowledge_card_id:number; version:number; status:string; content:Record<string,any> }
export interface ReviewItem { sop_id:number; sop_name:string; description:string; scenario_id:number; scenario_name:string; version_id:number; version:number; creator_id:number; creator_name:string; submitted_at: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; knowledge?:KnowledgeSnapshot }
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_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; knowledge?:KnowledgeSnapshot; outputs?:KnowledgeGroup[] }
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
export interface RunDetail extends SOPRun { scenario_name:string; version:number; operator_name: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

@@ -25,7 +25,7 @@ onMounted(async () => {
<a-skeleton :loading="loading" active>
<section class="metric-strip surface">
<div class="metric"><span class="metric-icon green"><AppstoreOutlined /></span><div><b>{{ summary.scenarios }}</b><small>可用场景</small></div></div>
<div class="metric"><span class="metric-icon coral"><CheckCircleOutlined /></span><div><b>{{ summary.published_sops }}</b><small>已发布 SOP</small></div></div>
<div class="metric"><span class="metric-icon coral"><CheckCircleOutlined /></span><div><b>{{ summary.published_sops }}</b><small>可用 SOP</small></div></div>
<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>
@@ -35,15 +35,14 @@ onMounted(async () => {
<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.review')" @click="router.push('/reviews')"><span>处理待审核 SOP<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('/knowledge')"><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 class="flow-note"><span>创建</span><i></i><span>配置</span><i></i><span>执行</span><i></i><span>复盘</span></div>
</div>
</section>
</div>

View File

@@ -1,24 +1,55 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined } from '@ant-design/icons-vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { CheckCircleOutlined, CopyOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined } 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 { ScenarioField, SOPNode, SOPRun } from '@/types'
import type { KnowledgeCollectionStep, PresentationField, PublicFieldView, ScenarioField, SOPNode, SOPRun } from '@/types'
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
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:['腹泻']}]},
customer:{name:'王女士'},
pet:{name:'团子',species:'猫'},
}
const route=useRoute();const router=useRouter()
const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
const loading=ref(true);const sops=ref<AvailableSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
const startOpen=ref(false);const selectedSOP=ref<AvailableSOP|null>(null)
const startForm=reactive({external_ref:'',input_text:'{}',initial_values:{} as Record<string,any>})
const finalResultText=ref('{}')
const resultSaved=ref(false)
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[]})
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items;const runID=Number(route.query.run||0);if(runID>0)active.value=await api.get<RunView>(`/runs/${runID}`)}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}finally{loading.value=false}}
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 startPresentation=computed(()=>active.value?.node.type==='start'?active.value.node.presentation:undefined)
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}}
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}}
async function clearAnswers(){Object.keys(answers).forEach(k=>delete answers[k]);await nextTick();answerForm.value?.clearValidate()}
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});await router.replace({query:{run:String(active.value.run.id)}});await clearAnswers();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}}
async function next(){if(!active.value)return;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 clearAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
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 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}}
function finishManually(){
if(!active.value)return
Modal.confirm({
@@ -36,7 +67,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||(['question','choice'].includes(active.value?.node.type||'')&&currentConfig.value.required===true)}
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 fieldRules(field:ScenarioField){
const rules:any[]=[]
if(fieldRequired(field))rules.push({required:true,message:`请填写${field.field_name}`,trigger:['change','blur']})
@@ -54,17 +85,19 @@ function fieldRules(field:ScenarioField){
return rules
}
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(()=>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="start(item.id)"><span class="entry-seq">{{ String(item.id).padStart(2,'0') }}</span><div><small>{{ item.scenario_name }} · V{{ item.version }}</small><h2>{{ item.name }}</h2><p>{{ item.description || '按标准步骤执行这套话术流程。' }}</p></div><span class="play"><PlayCircleOutlined /></span></button>
<a-empty v-if="!sops.length" description="还没有已发布的 SOP" />
<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>
<a-empty v-if="!sops.length" description="还没有可用的 SOP" />
</div>
<div v-else class="run-workspace">
<aside class="run-context">
@@ -76,13 +109,36 @@ onMounted(load)
<main class="conversation surface">
<div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div>
<div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div>
<section v-if="startPresentation" class="start-presentation">
<div v-if="startPresentation.summary.length" class="start-summary">
<div v-for="field in startPresentation.summary" :key="field.key"><small>{{ field.label }}</small><b>{{ presentationText(field.value) }}</b></div>
</div>
<div v-if="startPresentation.items.length" class="order-products">
<div class="presentation-heading"><b>订单商品</b><span>{{ startPresentation.items.length }} </span></div>
<article v-for="(item,index) in startPresentation.items" :key="index" class="order-product" :class="{'no-image':!itemImage(item)}">
<img v-if="itemImage(item)" :src="String(itemImage(item)?.value||'')" alt="订单商品图片" @error="hideBrokenImage" />
<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="active.node.knowledge" class="knowledge-snapshot">
<div class="knowledge-head"><b>{{ active.node.knowledge.title }}</b><span>知识 V{{ active.node.knowledge.version }}</span></div>
<div class="standard-copy"><small>标准话术</small><p>{{ active.node.knowledge.standard_copy }}</p></div>
<div class="knowledge-head"><b>{{ active.node.knowledge.title }}</b><span>历史知识</span></div>
<div v-for="copy in active.node.knowledge.copies" :key="copy.id" class="standard-copy"><small>{{ copy.title }}</small><p>{{ copy.content }}</p></div>
<div v-if="!active.node.knowledge.copies?.length" class="standard-copy"><small>标准话术</small><p>{{ active.node.knowledge.standard_copy }}</p></div>
<div v-if="active.node.knowledge.forbidden_copy" class="knowledge-note forbidden"><small>禁用表达</small><p>{{ active.node.knowledge.forbidden_copy }}</p></div>
<div v-if="active.node.knowledge.risk_note" class="knowledge-note risk"><small>风险提醒</small><p>{{ active.node.knowledge.risk_note }}</p></div>
</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><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>
<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>
<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-item v-for="field in currentFields" :key="field.id" :name="field.field_key" :label="field.field_name" :required="fieldRequired(field)" :rules="fieldRules(field)">
@@ -96,17 +152,39 @@ 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 ? '填写后继续下一步' : '确认当前话术已完成' }}</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>{{ 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>
</template>
</main>
</div>
</a-spin>
<a-modal v-model:open="startOpen" title="启动场景执行" ok-text="创建执行" :confirm-loading="submitting" width="760px" @ok="start">
<a-form layout="vertical"><a-form-item label="业务关联标识"><a-input v-model:value="startForm.external_ref" placeholder="例如 order:ORDER-001"/></a-form-item><a-form-item label="业务数据 JSON"><a-textarea v-model:value="startForm.input_text" :rows="15" spellcheck="false" class="json-input"/></a-form-item></a-form>
</a-modal>
</div>
</template>
<style scoped>
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.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-snapshot{margin:0 0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.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,.knowledge-note{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small,.knowledge-note small{font-size:10px;font-weight:750}.standard-copy p,.knowledge-note p{margin:6px 0 0;line-height:1.7}.knowledge-note{margin-top:8px;border-left-color:#c4573f;background:#fbf3f0}.knowledge-note.risk{border-left-color:#bd7b24;background:#fbf6eb}
@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}}
.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}
.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}
@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

@@ -1,138 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { BookOutlined, DeleteOutlined, EditOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { KnowledgeCard, KnowledgeCardVersion, Scenario } from '@/types'
const loading = ref(false)
const saving = ref(false)
const open = ref(false)
const historyOpen = ref(false)
const editingId = ref<number | null>(null)
const historyCard = ref<KnowledgeCard | null>(null)
const items = ref<KnowledgeCard[]>([])
const versions = ref<KnowledgeCardVersion[]>([])
const scenarios = ref<Scenario[]>([])
const auth = useAuthStore()
const form = reactive({ scenario_id: undefined as number | undefined, title: '', content: '', forbidden: '', risk_note: '' })
const scenarioOptions = computed(() => scenarios.value.map(item => ({ value: item.id, label: item.name })))
const drawerTitle = computed(() => editingId.value ? '编辑知识卡' : '新建知识卡')
async function load() {
loading.value = true
try {
const [cards, sceneData] = await Promise.all([api.get<{ items: KnowledgeCard[] }>('/knowledge-cards'), api.get<{ items: Scenario[] }>('/scenarios')])
items.value = cards.items || []
scenarios.value = sceneData.items || []
} catch (error) {
message.error(apiMessage(error))
} finally {
loading.value = false
}
}
function resetForm() {
editingId.value = null
Object.assign(form, { scenario_id: undefined, title: '', content: '', forbidden: '', risk_note: '' })
}
function openCreate() {
resetForm()
open.value = true
}
function openEdit(item: KnowledgeCard) {
editingId.value = item.id
Object.assign(form, {
scenario_id: item.scenario_id, title: item.title, content: contentOf(item, 'standard_copy'),
forbidden: contentOf(item, 'forbidden_copy'), risk_note: contentOf(item, 'risk_note'),
})
open.value = true
}
async function save() {
saving.value = true
const content = { standard_copy: form.content, forbidden_copy: form.forbidden, risk_note: form.risk_note }
try {
if (editingId.value) await api.put(`/knowledge-cards/${editingId.value}`, { title: form.title, content })
else await api.post('/knowledge-cards', { scenario_id: form.scenario_id, title: form.title, content })
message.success(editingId.value ? '知识卡新版本已发布' : '知识卡已发布')
open.value = false
resetForm()
await load()
} catch (error) {
message.error(apiMessage(error))
} finally {
saving.value = false
}
}
async function showHistory(item: KnowledgeCard) {
historyCard.value = item
try {
const data = await api.get<{ items: KnowledgeCardVersion[] }>(`/knowledge-cards/${item.id}/versions`)
versions.value = data.items || []
historyOpen.value = true
} catch (error) { message.error(apiMessage(error)) }
}
function remove(item: KnowledgeCard) {
Modal.confirm({
title: `归档“${item.title}”?`, content: '已发布或历史 SOP 版本引用的知识卡不能归档。', okText: '归档知识卡', okType: 'danger',
async onOk() {
try { await api.delete(`/knowledge-cards/${item.id}`); message.success('知识卡已归档'); await load() }
catch (error) { message.error(apiMessage(error)) }
},
})
}
function contentOf(item: { content?: Record<string, any> }, key: string) { return String(item.content?.[key] || '') }
onMounted(load)
</script>
<template>
<div class="page-shell">
<div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达禁用语和风险提醒</p></div><a-button v-if="auth.can('knowledge.edit')" type="primary" @click="openCreate"><PlusOutlined />新建知识卡</a-button></div>
<a-spin :spinning="loading">
<div v-if="items.length" class="knowledge-grid">
<article v-for="item in items" :key="item.id" class="knowledge-card surface">
<header><span class="card-icon"><BookOutlined /></span><div><a-tag color="green">已发布</a-tag><a-tag>V{{ item.version || 1 }}</a-tag></div></header>
<h2>{{ item.title }}</h2>
<p class="standard-copy">{{ contentOf(item, 'standard_copy') || '暂无标准话术' }}</p>
<div v-if="contentOf(item, 'forbidden_copy')" class="copy-note forbidden"><b>禁用表达</b><span>{{ contentOf(item, 'forbidden_copy') }}</span></div>
<div v-if="contentOf(item, 'risk_note')" class="copy-note risk"><b>风险提醒</b><span>{{ contentOf(item, 'risk_note') }}</span></div>
<footer><span>{{ scenarios.find(scene => scene.id === item.scenario_id)?.name || '未分类场景' }}</span><div class="card-actions"><a-tooltip title="版本历史"><a-button type="text" aria-label="查看版本历史" @click="showHistory(item)"><HistoryOutlined /></a-button></a-tooltip><a-tooltip v-if="auth.can('knowledge.edit')" title="编辑知识卡"><a-button type="text" aria-label="编辑知识卡" @click="openEdit(item)"><EditOutlined /></a-button></a-tooltip><a-tooltip v-if="auth.can('knowledge.edit')" title="归档知识卡"><a-button type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></a-tooltip></div></footer>
</article>
</div>
<a-empty v-else description="还没有知识卡" />
</a-spin>
<a-drawer v-if="auth.can('knowledge.edit')" v-model:open="open" :title="drawerTitle" width="520" @close="resetForm">
<a-form layout="vertical" :model="form" @finish="save">
<a-form-item label="所属场景" name="scenario_id" :rules="[{required:true,message:'请选择场景'}]"><a-select v-model:value="form.scenario_id" :disabled="Boolean(editingId)" :options="scenarioOptions" /></a-form-item>
<a-form-item label="标题" name="title" :rules="[{required:true,message:'请输入标题'}]"><a-input v-model:value="form.title" /></a-form-item>
<a-form-item label="标准话术" name="content" :rules="[{required:true,message:'请输入标准话术'}]"><a-textarea v-model:value="form.content" :rows="5" /></a-form-item>
<a-form-item label="禁用表达"><a-textarea v-model:value="form.forbidden" :rows="3" /></a-form-item>
<a-form-item label="风险提醒"><a-textarea v-model:value="form.risk_note" :rows="3" /></a-form-item>
<a-button type="primary" html-type="submit" block :loading="saving">{{ editingId ? '发布新版本' : '发布知识卡' }}</a-button>
</a-form>
</a-drawer>
<a-drawer v-model:open="historyOpen" :title="`${historyCard?.title || '知识卡'} · 版本历史`" width="560">
<div class="version-list">
<section v-for="item in versions" :key="item.id" class="version-row">
<div class="version-head"><b>V{{ item.version }}</b><a-tag :color="item.status === 'published' ? 'green' : 'default'">{{ item.status === 'published' ? '当前版本' : '历史版本' }}</a-tag><time>{{ new Date(item.created_at).toLocaleString('zh-CN') }}</time></div>
<p>{{ contentOf(item, 'standard_copy') }}</p>
<div v-if="contentOf(item, 'forbidden_copy')" class="history-note"><b>禁用表达</b>{{ contentOf(item, 'forbidden_copy') }}</div>
<div v-if="contentOf(item, 'risk_note')" class="history-note"><b>风险提醒</b>{{ contentOf(item, 'risk_note') }}</div>
</section>
</div>
</a-drawer>
</div>
</template>
<style scoped>
.knowledge-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.knowledge-card{min-height:300px;padding:20px;display:flex;flex-direction:column}.knowledge-card header{display:flex;justify-content:space-between}.card-icon{width:36px;height:36px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.knowledge-card h2{margin:20px 0 10px;font-family:"Noto Serif SC",serif;font-size:19px}.standard-copy{margin:0;color:#4f5c56;line-height:1.7}.copy-note{display:grid;grid-template-columns:58px 1fr;gap:8px;margin-top:12px;padding:9px 11px;font-size:12px;border-left:2px solid}.copy-note b{font-size:11px}.copy-note.forbidden{color:#7a4d4d;background:#faf0f0;border-color:#ba7474}.copy-note.risk{color:#7f591c;background:#fbf3e5;border-color:#c68a37}.knowledge-card footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:18px;color:var(--muted);font-size:12px}.card-actions{display:flex}.version-row{padding:18px 0;border-bottom:1px solid var(--line)}.version-row:first-child{padding-top:0}.version-head{display:flex;align-items:center;gap:8px}.version-head time{margin-left:auto;color:var(--muted);font-size:11px}.version-row>p{margin:12px 0;color:#45524c;line-height:1.7}.history-note{margin-top:7px;color:#6f7b76;font-size:12px}.history-note b{margin-right:8px;color:#39463f}@media(max-width:1050px){.knowledge-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.knowledge-grid{grid-template-columns:1fr}.version-head{flex-wrap:wrap}.version-head time{width:100%;margin-left:0}}
</style>

View File

@@ -35,7 +35,7 @@ async function submit() {
<div class="identity-copy">
<p class="eyebrow">SALES PRACTICE SYSTEM</p>
<h1>把经验变成<br />每天可执行的步骤</h1>
<p>场景配置话术编排版本发布与一线执行在同一个工作台完成</p>
<p>场景契约知识关系话术编排与一线执行在同一个工作台完成</p>
</div>
<div class="signal-line"><span>01</span><b></b><span>结构化经验</span></div>
</section>

View File

@@ -20,8 +20,8 @@ onMounted(load)
<template>
<div class="page-shell members-page">
<div class="page-heading"><div><h1>企业成员</h1><p>分配 SOP 编辑审核和一线执行职责权限变更会立即生效</p></div><a-button type="primary" @click="open=true"><PlusOutlined />添加成员</a-button></div>
<section class="member-summary"><TeamOutlined /><div><b>{{ items.filter(item=>item.status==='active').length }}</b><span>在用账号</span></div><p>管理员拥有全部权限编辑者维护场景与草稿审核者负责发布执行者使用已发布 SOP 和自己的记录</p></section>
<div class="page-heading"><div><h1>企业成员</h1><p>分配场景维护SOP 编辑和一线执行职责权限变更会立即生效</p></div><a-button type="primary" @click="open=true"><PlusOutlined />添加成员</a-button></div>
<section class="member-summary"><TeamOutlined /><div><b>{{ items.filter(item=>item.status==='active').length }}</b><span>在用账号</span></div><p>管理员拥有全部权限维护者配置场景契约知识和 SOP执行者使用可用 SOP 和自己的记录</p></section>
<section class="surface"><a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="false"><template #bodyCell="{column,record}"><template v-if="column.key==='member'"><div class="member-cell"><span>{{ record.display_name.slice(0,1) }}</span><div><b>{{ record.display_name }}</b><small>{{ record.username }}</small></div></div></template><template v-else-if="column.dataIndex==='role_name'"><a-tag>{{ record.role_name }}</a-tag></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='active'?'green':'default'">{{ record.status==='active'?'使用中':'已停用' }}</a-tag></template><template v-else-if="column.key==='action'"><a-button v-if="record.user_id!==auth.user?.user_id" type="text" aria-label="编辑成员权限" @click="edit(record)"><EditOutlined /></a-button></template></template></a-table></section>
<a-drawer v-model:open="open" title="添加企业成员" width="480"><a-form layout="vertical" :model="form" @finish="create"><div class="form-grid"><a-form-item label="登录用户名" name="username" :rules="[{required:true,message:'请输入用户名'}]"><a-input v-model:value="form.username" /></a-form-item><a-form-item label="显示名称" name="display_name" :rules="[{required:true,message:'请输入显示名称'}]"><a-input v-model:value="form.display_name" /></a-form-item></div><a-form-item label="初始密码" name="password" :rules="[{required:true,min:6,message:'密码至少 6 位'}]"><a-input-password v-model:value="form.password" /></a-form-item><a-form-item label="角色" name="role_code" :rules="[{required:true}]"><a-select v-model:value="form.role_code" :options="roleOptions" /></a-form-item><a-button type="primary" html-type="submit" block :loading="saving">创建成员</a-button></a-form></a-drawer>
<a-modal :open="Boolean(editing)" title="调整成员权限" ok-text="保存" :confirm-loading="saving" @ok="update" @cancel="editing=null"><div v-if="editing" class="editing-name"><b>{{ editing.display_name }}</b><span>{{ editing.username }}</span></div><a-form layout="vertical"><a-form-item label="角色"><a-select v-model:value="editForm.role_code" :options="roleOptions" /></a-form-item><a-form-item label="账号状态"><a-segmented v-model:value="editForm.status" :options="[{value:'active',label:'使用中'},{value:'disabled',label:'停用'}]" /></a-form-item></a-form></a-modal>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { CheckOutlined, ClockCircleOutlined, EyeOutlined, StopOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import type { ReviewItem } from '@/types'
const router=useRouter();const loading=ref(false);const items=ref<ReviewItem[]>([]);const rejecting=ref<ReviewItem|null>(null);const submitting=ref(false);const rejectForm=reactive({reason:''})
const columns=[{title:'SOP / 场景',key:'sop'},{title:'版本',dataIndex:'version',width:90},{title:'提交人',dataIndex:'creator_name',width:140},{title:'等待时间',dataIndex:'submitted_at',width:180},{title:'',key:'action',width:250}]
async function load(){loading.value=true;try{const data=await api.get<{items:ReviewItem[]}>('/reviews');items.value=data.items||[]}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function approve(item:ReviewItem){submitting.value=true;try{await api.post(`/sops/${item.sop_id}/publish`);message.success(`${item.sop_name}”已通过并发布`);await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function openReject(item:ReviewItem){rejecting.value=item;rejectForm.reason=''}
async function reject(){if(!rejecting.value||!rejectForm.reason.trim())return message.warning('请填写退回原因');submitting.value=true;try{await api.post(`/sops/${rejecting.value.sop_id}/reject`,{reason:rejectForm.reason});message.success('已退回编辑者修改');rejecting.value=null;await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function waiting(value:string){const minutes=Math.max(1,Math.round((Date.now()-new Date(value).getTime())/60000));if(minutes<60)return`${minutes} 分钟`;const hours=Math.round(minutes/60);return hours<48?`${hours} 小时`:`${Math.round(hours/24)} `}
onMounted(load)
</script>
<template>
<div class="page-shell review-page">
<div class="page-heading"><div><h1>审核中心</h1><p>检查流程完整性风险分支和话术口径后再决定发布</p></div><div class="queue-count"><ClockCircleOutlined /><b>{{ items.length }}</b><span>待审核</span></div></div>
<section class="surface review-table">
<div class="review-guideline"><span>审核关注</span><b>字段完整</b><i></i><b>条件可达</b><i></i><b>高风险有出口</b><i></i><b>知识卡已发布</b></div>
<a-table row-key="version_id" :loading="loading" :data-source="items" :columns="columns" :pagination="false" :scroll="{x:850}">
<template #bodyCell="{column,record}"><template v-if="column.key==='sop'"><div class="sop-cell"><b>{{ record.sop_name }}</b><span>{{ record.scenario_name }} · {{ record.description||'暂无说明' }}</span></div></template><template v-else-if="column.dataIndex==='version'"><a-tag>V{{ record.version }}</a-tag></template><template v-else-if="column.dataIndex==='submitted_at'"><span class="waiting">{{ waiting(record.submitted_at) }}</span></template><template v-else-if="column.key==='action'"><div class="row-actions"><a-button @click="router.push(`/sops/${record.sop_id}`)"><EyeOutlined />查看</a-button><a-button danger @click="openReject(record)"><StopOutlined />退回</a-button><a-button type="primary" :loading="submitting" @click="approve(record)"><CheckOutlined />通过</a-button></div></template></template>
<template #emptyText><div class="review-empty"><CheckOutlined /><b>审核队列已清空</b><span>新提交的 SOP 会出现在这里</span></div></template>
</a-table>
</section>
<a-modal :open="Boolean(rejecting)" title="退回修改" ok-text="确认退回" :confirm-loading="submitting" @ok="reject" @cancel="rejecting=null"><p class="modal-copy">请说明{{ rejecting?.sop_name }}需要调整的具体内容编辑者可据此修改后重新提交</p><a-textarea v-model:value="rejectForm.reason" :rows="5" :maxlength="1000" show-count placeholder="例如:急症判断缺少默认路径,请补充非急症分支。" /></a-modal>
</div>
</template>
<style scoped>
.queue-count{height:54px;display:grid;grid-template-columns:24px 32px auto;align-items:center;gap:8px;padding:0 16px;color:#3f4c46;background:#fff;border:1px solid var(--line);border-radius:6px}.queue-count>span:first-child{color:var(--amber)}.queue-count b{font-family:"Noto Serif SC",serif;font-size:24px}.queue-count span:last-child{color:var(--muted);font-size:12px}.review-table{overflow:hidden}.review-guideline{min-height:48px;display:flex;align-items:center;gap:12px;padding:0 16px;color:#637069;background:#f7f9f8;border-bottom:1px solid var(--line);font-size:11px}.review-guideline>span{font-weight:750;color:#28332e}.review-guideline i{width:3px;height:3px;border-radius:50%;background:#a7b2ad}.sop-cell{display:flex;flex-direction:column;max-width:520px}.sop-cell b{font-size:14px}.sop-cell span{margin-top:5px;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.waiting{color:#8a641e}.row-actions{display:flex;justify-content:flex-end;gap:7px}.review-empty{min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#9aa59f}.review-empty>span:first-child{font-size:34px;color:#4eaa88}.review-empty b{margin-top:12px;color:#415049}.review-empty span:last-child{margin-top:5px;font-size:12px}.modal-copy{margin:0 0 14px;color:var(--muted);line-height:1.7}@media(max-width:650px){.review-guideline{display:none}.queue-count{display:none}}
</style>

View File

@@ -11,9 +11,10 @@ interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];
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 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:'人工结束'}[v]||v}
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',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)}
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}}
@@ -27,7 +28,7 @@ onMounted(load)
<a-spin :spinning="loading">
<template v-if="detail">
<header class="detail-heading">
<div><div class="run-code">RUN-{{ String(detail.run.id).padStart(5,'0') }}</div><h1>{{ detail.run.sop_name }}</h1><p>{{ detail.run.scenario_name }} · V{{ detail.run.version }} · {{ detail.run.operator_name }}</p></div>
<div><div class="run-code">RUN-{{ String(detail.run.id).padStart(5,'0') }}</div><h1>{{ detail.run.sop_name }}</h1><p>{{ detail.run.scenario_name }} · {{ detail.run.operator_name }}</p></div>
<div class="result-block" :class="detail.run.result==='escalate'?'warning':'success'"><SafetyCertificateOutlined v-if="detail.run.result==='escalate'"/><CheckCircleOutlined v-else/><span><small>{{ detail.run.status==='completed'?'执行结果':'当前状态' }}</small><b>{{ resultText(detail.run.result) }}</b></span></div>
</header>
@@ -38,7 +39,8 @@ onMounted(load)
<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'">
<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-if="event.action==='enter'&&event.knowledge" class="event-knowledge"><div><b>{{ event.knowledge.title }}</b><small>V{{ event.knowledge.version }}</small></div><p>{{ event.knowledge.standard_copy }}</p><p v-if="event.knowledge.risk_note" class="risk-note">{{ event.knowledge.risk_note }}</p></div>
<div v-if="event.action==='enter'&&event.knowledge" class="event-knowledge"><div><b>{{ event.knowledge.title }}</b><small>历史知识</small></div><p>{{ event.knowledge.standard_copy }}</p><p v-if="event.knowledge.risk_note" class="risk-note">{{ event.knowledge.risk_note }}</p></div>
<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>
<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,6 +49,7 @@ 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>{{ 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>
</aside>
</section>
@@ -64,4 +67,5 @@ onMounted(load)
<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}
</style>

View File

@@ -38,7 +38,7 @@ onMounted(()=>{load();loadOptions()})
<a-button type="text" aria-label="刷新执行记录" @click="load()"><ReloadOutlined /></a-button>
</div>
<a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="{current:page,pageSize,total,showSizeChanger:true,showTotal:(value:number)=>`共 ${value} 条`}" :scroll="{x:980}" :custom-row="rowProps" @change="tableChange">
<template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.key==='sop'"><div class="run-sop"><b>{{ record.sop_name }}</b><small>{{ record.scenario_name }} · V{{ record.version }}</small></div></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><a-tooltip v-if="canContinue(record)" title="继续执行"><a-button type="text" aria-label="继续执行" @click.stop="continueRun(record)"><PlayCircleOutlined /></a-button></a-tooltip><RightOutlined v-else class="row-arrow" /></template></template>
<template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.key==='sop'"><div class="run-sop"><b>{{ record.sop_name }}</b><small>{{ record.scenario_name }}</small></div></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><a-tooltip v-if="canContinue(record)" title="继续执行"><a-button type="text" aria-label="继续执行" @click.stop="continueRun(record)"><PlayCircleOutlined /></a-button></a-tooltip><RightOutlined v-else class="row-arrow" /></template></template>
<template #emptyText><div class="empty-copy">当前筛选条件下没有执行记录</div></template>
</a-table>
</section>

View File

@@ -1,28 +1,28 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, HistoryOutlined, PlusOutlined, RollbackOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { ScenarioField, SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
interface KnowledgeCard { id:number; scenario_id:number; title:string }
import type { ScenarioField, SOP, SOPEdge, SOPNode } from '@/types'
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
const auth = useAuthStore()
const loading = ref(true); const saving = ref(false)
const historyOpen = ref(false); const rollingBack = ref(false)
const sop = ref<SOP | null>(null); const version = ref<SOPVersion | null>(null)
const versions = ref<SOPVersion[]>([])
const sop = ref<SOP | null>(null); const graph = ref<{start_node_key:string} | null>(null)
const nodes = ref<SOPNode[]>([]); const edges = ref<SOPEdge[]>([]); const selectedKey = ref('')
const fields = ref<ScenarioField[]>([]); const knowledgeCards = ref<KnowledgeCard[]>([])
const fields = ref<ScenarioField[]>([])
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
const editable = computed(() => version.value?.status === 'draft')
const canEdit = computed(() => editable.value&&auth.can('sop.edit'))
const reviewing = computed(() => version.value?.status === 'reviewing')
const isLatestVersion = computed(() => version.value?.version === Math.max(0, ...versions.value.map(item => item.version)))
const hasPendingVersion = computed(() => versions.value.some(item => ['draft','reviewing'].includes(item.status)))
const startImageFieldOptions = computed(() => {
const keys = selected.value?.config?.presentation?.item_field_keys || []
return fields.value.filter(field=>keys.includes(field.field_key)).map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))
})
const formRequiredFieldOptions = computed(() => {
const keys = selected.value?.config?.field_keys || []
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:'结束'}]
interface ConditionRuleDraft { field:string; operator:string; value:string }
const newConditionRule = ():ConditionRuleDraft => ({ field:'', operator:'equals', value:'' })
@@ -31,15 +31,10 @@ const edgeDraft = reactive({ target_node_key: '', conditional: false, mode: 'all
async function load() {
loading.value = true
try {
const requestedVersion = Number(route.query.version || 0)
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`, { params: requestedVersion ? { version: requestedVersion } : {} })
sop.value=data.sop; version.value=data.version; nodes.value=data.nodes.map(n=>({...n,config:n.config||{}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value = nodes.value[0]?.node_key || ''
const [scenarioData, cardData, versionData] = await Promise.all([
api.get<{ fields:ScenarioField[] }>(`/scenarios/${data.sop.scenario_id}`),
api.get<{ items:KnowledgeCard[] }>('/knowledge-cards'),
api.get<{ items:SOPVersion[] }>(`/sops/${id}/versions`),
])
fields.value=scenarioData.fields; knowledgeCards.value=(cardData.items||[]).filter(card=>card.scenario_id===data.sop.scenario_id); versions.value=versionData.items||[]
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 || ''
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 }
}
function addNode() {
@@ -70,43 +65,46 @@ function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(item=>item!==ed
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 knowledgeCardChanged(){if(selected.value)delete selected.value.config.knowledge_card_version_id}
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}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load();return true}catch(error){message.error(apiMessage(error));return false}finally{saving.value=false}}
async function validate(){try{const suffix=route.query.version?`?version=${encodeURIComponent(String(route.query.version))}`:'';const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate${suffix}`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join('')})}catch(error){message.error(apiMessage(error))}}
async function submitReview(){try{if(!(await save()))return;await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
async function publish(){try{if(canEdit.value&&!(await save()))return;await api.post(`/sops/${id}/publish`);message.success('SOP 已发布');await load()}catch(error){message.error(apiMessage(error))}}
async function offline(){try{await api.post(`/sops/${id}/offline`);message.success('SOP 已下线');await load()}catch(error){message.error(apiMessage(error))}}
async function createVersion(){try{await api.post(`/sops/${id}/versions`);await router.replace({query:{}});message.success('已创建新草稿版本');await load()}catch(error){message.error(apiMessage(error))}}
async function viewVersion(value:number){await router.replace({query:{version:String(value)}});historyOpen.value=false;await load()}
function rollbackVersion(item:SOPVersion){Modal.confirm({title:`回滚到 V${item.version}`,content:'系统会复制该历史版本并生成一个新的已发布版本,当前已发布版本将保留为历史记录。',okText:'确认回滚',async onOk(){rollingBack.value=true;try{const data=await api.post<{version:number}>(`/sops/${id}/rollback`,{version:item.version});await router.replace({query:{}});historyOpen.value=false;message.success(`已回滚并发布为 V${data.version}`);await load()}catch(error){message.error(apiMessage(error))}finally{rollingBack.value=false}}})}
function versionStatus(value:string){return({draft:'草稿',reviewing:'审核中',published:'已发布',superseded:'历史版本',offline:'已下线'} as Record<string,string>)[value]||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))}}
onMounted(load)
</script>
<template>
<div class="editor-page">
<header class="editor-header">
<div class="editor-title"><a-button type="text" aria-label="返回场景" @click="router.push(`/scenarios/${sop?.scenario_id}`)"><ArrowLeftOutlined /></a-button><div><b>{{ sop?.name || 'SOP 编辑器' }}</b><span>版本 {{ version?.version }} · {{ versionStatus(version?.status || '') }}</span></div></div>
<div class="page-actions"><a-button @click="historyOpen=true"><HistoryOutlined />版本历史</a-button><a-button @click="validate">校验流程</a-button><a-button v-if="canEdit" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="canEdit&&auth.can('sop.submit_review')" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="auth.can('sop.publish')&&(reviewing||(editable&&auth.can('*')))" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'&&auth.can('sop.publish')" danger @click="offline">下线</a-button><a-button v-if="auth.can('sop.edit')&&isLatestVersion&&!hasPendingVersion&&!editable&&version?.status !== 'reviewing'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button></div>
<div class="editor-title"><a-button type="text" aria-label="返回场景" @click="router.push(`/scenarios/${sop?.scenario_id}`)"><ArrowLeftOutlined /></a-button><div><b>{{ sop?.name || 'SOP 编辑器' }}</b><span>流程配置</span></div></div>
<div class="page-actions"><a-button @click="validate">校验流程</a-button><a-button v-if="canEdit" type="primary" :loading="saving" @click="save"><SaveOutlined />保存并生效</a-button></div>
</header>
<a-spin :spinning="loading">
<main class="editor-workbench">
<aside class="node-rail surface">
<div class="rail-title"><span>流程节点</span><a-button v-if="canEdit" type="text" aria-label="添加节点" @click="addNode"><PlusOutlined /></a-button></div>
<div class="node-list">
<button v-for="(node,index) in nodes" :key="node.node_key" :class="{active:selectedKey===node.node_key}" @click="selectedKey=node.node_key"><span class="node-index">{{ String(index+1).padStart(2,'0') }}</span><span class="node-meta"><b>{{ node.title }}</b><small>{{ nodeTypes.find(t=>t.value===node.type)?.label || node.type }}</small></span><span v-if="canEdit&&node.type!=='start'" class="node-move"><ArrowUpOutlined @click.stop="move(index,-1)"/><ArrowDownOutlined @click.stop="move(index,1)"/></span></button>
<div v-for="(node,index) in nodes" :key="node.node_key" class="node-list-item" :class="{active:selectedKey===node.node_key}" role="button" tabindex="0" @click="selectedKey=node.node_key" @keydown.enter="selectedKey=node.node_key"><span class="node-index">{{ String(index+1).padStart(2,'0') }}</span><span class="node-meta"><b>{{ node.title }}</b><small>{{ nodeTypes.find(t=>t.value===node.type)?.label || node.type }}</small></span><span v-if="node.type!=='start'" class="node-actions"><a-tooltip :title="canEdit ? '上移节点' : '无编辑权限' "><a-button type="text" size="small" aria-label="上移节点" :disabled="!canEdit" @click.stop="move(index,-1)"><ArrowUpOutlined /></a-button></a-tooltip><a-tooltip :title="canEdit ? '下移节点' : '无编辑权限' "><a-button type="text" size="small" aria-label="下移节点" :disabled="!canEdit" @click.stop="move(index,1)"><ArrowDownOutlined /></a-button></a-tooltip><a-tooltip :title="canEdit ? '删除节点' : '无编辑权限' "><a-button type="text" size="small" danger aria-label="删除节点" :disabled="!canEdit" @click.stop="removeNode(node)"><DeleteOutlined /></a-button></a-tooltip></span></div>
</div>
</aside>
<section v-if="selected" class="node-editor surface">
<div class="node-editor-head"><div><span class="node-type-mark">{{ selected.type.toUpperCase() }}</span><h2>{{ selected.title }}</h2></div><a-button v-if="canEdit&&selected.type!=='start'" danger type="text" @click="removeNode(selected)"><DeleteOutlined />删除</a-button></div>
<div class="node-editor-head"><div><span class="node-type-mark">{{ selected.type.toUpperCase() }}</span><h2>{{ selected.title }}</h2></div><a-tooltip v-if="selected.type!=='start'" :title="canEdit ? '删除节点及其关联路径' : '无编辑权限' "><a-button danger type="text" :disabled="!canEdit" @click="removeNode(selected)"><DeleteOutlined />删除</a-button></a-tooltip></div>
<a-form layout="vertical" class="property-form">
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!canEdit" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!canEdit||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!canEdit" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
<template v-if="selected.type==='start'">
<div class="property-grid"><a-form-item label="摘要展示字段"><a-select v-model:value="selected.config.presentation.summary_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.presentation.item_field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="商品名称、图片、ID 等数组字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item></div>
<div class="property-grid"><a-form-item label="图片字段"><a-select v-model:value="selected.config.presentation.image_field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择需要按图片渲染的字段" :options="startImageFieldOptions" /></a-form-item><a-form-item label="话术标题"><a-input v-model:value="selected.config.presentation.opening_title" :disabled="!canEdit" /></a-form-item></div>
<a-form-item label="开场话术模板"><a-textarea v-model:value="selected.config.presentation.opening_template" :disabled="!canEdit" :rows="4" placeholder="例如:您好,{{input.customer_name}}。看到您购买了{{input.product_names}}。" /></a-form-item>
</template>
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!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>
<a-form-item v-if="selected.type==='form'" 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>
<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>
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" @change="knowledgeCardChanged" /><div v-if="selected.config.knowledge_card_version_id" class="snapshot-note">已发布快照知识卡版本 ID {{ selected.config.knowledge_card_version_id }}</div></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>
</a-form>
<div class="transition-head"><div><b>下一步路径</b><span>条件路径优先匹配默认路径始终排在最后</span></div></div>
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="canEdit" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
@@ -122,20 +120,11 @@ onMounted(load)
<a-empty v-else description="请选择一个节点" class="surface editor-empty" />
</main>
</a-spin>
<a-drawer v-model:open="historyOpen" title="SOP 版本历史" width="580">
<div class="version-list">
<section v-for="item in versions" :key="item.id" class="version-row" :class="{current:item.version===version?.version}">
<div class="version-number">V{{ item.version }}</div>
<div class="version-copy"><div><a-tag :color="item.status==='published'?'green':item.status==='reviewing'?'orange':'default'">{{ versionStatus(item.status) }}</a-tag><span>{{ item.creator_name || '未知创建人' }}</span></div><time>{{ new Date(item.published_at || item.created_at).toLocaleString('zh-CN') }}</time></div>
<div class="version-actions"><a-button size="small" @click="viewVersion(item.version)">查看</a-button><a-tooltip v-if="auth.can('sop.publish')&&['superseded','offline'].includes(item.status)&&!hasPendingVersion" title="复制该版本并作为新版本发布"><a-button size="small" :loading="rollingBack" @click="rollbackVersion(item)"><RollbackOutlined />回滚</a-button></a-tooltip></div>
</section>
</div>
</a-drawer>
</div>
</template>
<style scoped>
.editor-page{min-height:calc(100vh - 62px);background:#eef1ef}.editor-header{min-height:66px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 24px;background:white;border-bottom:1px solid var(--line);position:sticky;top:62px;z-index:10}.editor-title{display:flex;align-items:center;gap:8px}.editor-title>div{display:flex;flex-direction:column}.editor-title b{font-size:15px}.editor-title span{margin-top:3px;color:var(--muted);font-size:11px}.page-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.editor-workbench{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px}.node-rail{align-self:start;overflow:hidden;position:sticky;top:146px}.rail-title{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);font-weight:650}.node-list{padding:8px}.node-list button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;padding:7px 8px;text-align:left;background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}.node-list button:hover{background:#f5f8f6}.node-list button.active{background:#edf6f2;border-color:#b9d8cc}.node-index{color:#88958f;font-family:monospace;font-size:11px}.node-meta{min-width:0;display:flex;flex-direction:column}.node-meta b{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.node-meta small{margin-top:3px;color:var(--muted);font-size:11px}.node-move{display:flex;gap:4px;color:#87938e}.node-move>*:hover{color:var(--green)}.node-editor{align-self:start;min-height:600px;padding:24px}.node-editor-head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--line)}.node-editor-head h2{margin:6px 0 0;font-family:"Noto Serif SC",serif;font-size:22px}.node-type-mark{color:var(--green);font-size:10px;font-weight:750}.property-form{padding-top:22px}.property-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.transition-head{display:flex;justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;border-top:1px solid var(--line)}.transition-head>div{display:flex;flex-direction:column}.transition-head span{margin-top:4px;color:var(--muted);font-size:11px}.transition-row{min-height:48px;display:grid;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;border-bottom:1px solid #edf0ee}.route-line{width:16px;height:1px;background:var(--green)}.route-target{font-weight:600}.route-empty{padding:20px 0;color:var(--muted);font-size:12px}.edge-builder{padding-top:16px}.edge-main{display:grid;grid-template-columns:minmax(220px,1fr) auto auto;align-items:center;gap:10px}.condition-toggle{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.condition-builder{margin-top:12px;padding:14px;background:#f7f9f8;border:1px solid var(--line);border-radius:4px}.condition-mode{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;color:var(--muted);font-size:12px}.condition-rule{display:grid;grid-template-columns:1.2fr 130px 1fr 34px;gap:8px;margin-top:8px}.editor-empty{min-height:400px;display:grid;place-items:center}.version-row{display:grid;grid-template-columns:54px 1fr auto;align-items:center;gap:12px;padding:16px 8px;border-bottom:1px solid var(--line)}.version-row.current{background:#f1f7f4}.version-number{font-weight:750;color:#245f4d}.version-copy>div{display:flex;align-items:center;gap:6px}.version-copy time{display:block;margin-top:5px;color:var(--muted);font-size:11px}.version-actions{display:flex;gap:6px}
.editor-page{min-height:calc(100vh - 62px);background:#eef1ef}.editor-header{min-height:66px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 24px;background:white;border-bottom:1px solid var(--line);position:sticky;top:62px;z-index:10}.editor-title{display:flex;align-items:center;gap:8px}.editor-title>div{display:flex;flex-direction:column}.editor-title b{font-size:15px}.editor-title span{margin-top:3px;color:var(--muted);font-size:11px}.page-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.editor-workbench{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px}.node-rail{align-self:start;overflow:hidden;position:sticky;top:146px}.rail-title{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);font-weight:650}.node-list{padding:8px}.node-list-item{width:100%;min-height:58px;display:grid;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:9px;padding:7px 8px;text-align:left;background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}.node-list-item:hover{background:#f5f8f6}.node-list-item.active{background:#edf6f2;border-color:#b9d8cc}.node-index{color:#88958f;font-family:monospace;font-size:11px}.node-meta{min-width:0;display:flex;flex-direction:column}.node-meta b{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.node-meta small{margin-top:3px;color:var(--muted);font-size:11px}.node-actions{display:flex;gap:1px}.node-actions .ant-btn{width:24px;padding:0;color:#87938e}.node-actions .ant-btn:hover{color:var(--green)}.node-actions .ant-btn-dangerous:hover{color:#c34c4c}.node-editor{align-self:start;min-height:600px;padding:24px}.node-editor-head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--line)}.node-editor-head h2{margin:6px 0 0;font-family:"Noto Serif SC",serif;font-size:22px}.node-type-mark{color:var(--green);font-size:10px;font-weight:750}.property-form{padding-top:22px}.property-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.transition-head{display:flex;justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;border-top:1px solid var(--line)}.transition-head>div{display:flex;flex-direction:column}.transition-head span{margin-top:4px;color:var(--muted);font-size:11px}.transition-row{min-height:48px;display:grid;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;border-bottom:1px solid #edf0ee}.route-line{width:16px;height:1px;background:var(--green)}.route-target{font-weight:600}.route-empty{padding:20px 0;color:var(--muted);font-size:12px}.edge-builder{padding-top:16px}.edge-main{display:grid;grid-template-columns:minmax(220px,1fr) auto auto;align-items:center;gap:10px}.condition-toggle{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.condition-builder{margin-top:12px;padding:14px;background:#f7f9f8;border:1px solid var(--line);border-radius:4px}.condition-mode{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;color:var(--muted);font-size:12px}.condition-rule{display:grid;grid-template-columns:1.2fr 130px 1fr 34px;gap:8px;margin-top:8px}.editor-empty{min-height:400px;display:grid;place-items:center}
.snapshot-note{margin-top:6px;color:var(--muted);font-size:11px}
@media(max-width:1050px){.editor-workbench{grid-template-columns:230px minmax(0,1fr)}.condition-rule{grid-template-columns:1fr 120px 1fr 34px}}@media(max-width:760px){.editor-header{top:62px;align-items:flex-start;flex-direction:column;padding:12px}.page-actions{justify-content:flex-start}.editor-workbench{grid-template-columns:1fr;padding:10px}.node-rail{position:static}.property-grid{grid-template-columns:1fr}.node-editor{padding:16px}.transition-head{margin:8px -16px 0;padding:16px}.edge-main,.condition-rule{grid-template-columns:1fr}.condition-rule .ant-btn{justify-self:end}.version-row{grid-template-columns:46px 1fr}.version-actions{grid-column:2}}
@media(max-width:1050px){.editor-workbench{grid-template-columns:230px minmax(0,1fr)}.condition-rule{grid-template-columns:1fr 120px 1fr 34px}}@media(max-width:760px){.editor-header{top:62px;align-items:flex-start;flex-direction:column;padding:12px}.page-actions{justify-content:flex-start}.editor-workbench{grid-template-columns:1fr;padding:10px}.node-rail{position:static}.property-grid{grid-template-columns:1fr}.node-editor{padding:16px}.transition-head{margin:8px -16px 0;padding:16px}.edge-main,.condition-rule{grid-template-columns:1fr}.condition-rule .ant-btn{justify-self:end}}
</style>

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeftOutlined, BranchesOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { ArrowLeftOutlined, BranchesOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons-vue'
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'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const scenarioId = Number(route.params.id)
const activeTab = ref(String(route.query.tab || 'fields'))
const loading = ref(true)
const saving = ref(false)
const scenario = ref<Scenario | null>(null)
@@ -21,13 +23,17 @@ const fieldOpen = ref(false)
const sopOpen = ref(false)
const editingFieldId = ref<number | null>(null)
const scenarioForm = reactive({ name: '', industry: '', role_name: '', goal: '', trigger_text: '', visibility: 'tenant' })
const fieldForm = reactive({ field_key: '', field_name: '', 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 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 previewResult = ref<any>(null)
const previewing = ref(false)
const statusColor = computed(() => scenario.value?.status === 'active' ? 'green' : 'default')
const fieldModalTitle = computed(() => editingFieldId.value ? '编辑场景字段' : '添加场景字段')
const fieldModalTitle = computed(() => editingFieldId.value ? '编辑场景输入字段' : '添加场景输入字段')
const fieldTypeOptions = [
{ value: 'text', label: '单行文本' }, { value: 'textarea', label: '多行文本' }, { value: 'number', label: '数字' },
{ value: 'boolean', label: '是/否' }, { value: 'select', label: '单选' }, { value: 'multiselect', label: '多选' }, { value: 'date', label: '日期' },
{ value: 'boolean', label: '是/否' }, { value: 'select', label: '单选' }, { value: 'multiselect', label: '多选' }, { value: 'array', label: '数组' }, { value: 'date', label: '日期' },
]
async function load() {
@@ -44,6 +50,10 @@ async function load() {
}
}
async function loadContract(){try{const data=await api.get<any>(`/scenarios/${scenarioId}/contract`);contractText.value=JSON.stringify({output_schema:data.output_schema||{fields:[]},result_schema:data.result_schema||{fields:[]},rules:(data.rules||[]).map((rule:any)=>({rule_key:rule.rule_key,name:rule.name,condition:rule.condition,actions:rule.actions,priority:rule.priority,status:rule.status})),allowed_origins:data.allowed_origins||[]},null,2)}catch(error){message.error(apiMessage(error))}}
async function saveContract(){let payload:any;try{payload=JSON.parse(contractText.value)}catch{return message.warning('规则与输出 JSON 格式不正确')}saving.value=true;try{await api.put(`/scenarios/${scenarioId}/contract`,payload);message.success('规则与输出契约已保存');await loadContract()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
async function previewScenario(){let payload:any;try{payload=JSON.parse(previewText.value)}catch{return message.warning('预览 JSON 格式不正确')}previewing.value=true;try{previewResult.value=await api.post(`/scenarios/${scenarioId}/preview`,payload);message.success('预览完成')}catch(error){message.error(apiMessage(error))}finally{previewing.value=false}}
function openScenarioEditor() {
if (!scenario.value) return
Object.assign(scenarioForm, {
@@ -70,7 +80,7 @@ async function updateScenario() {
function archiveScenario() {
Modal.confirm({
title: `归档“${scenario.value?.name}”?`,
content: '归档后场景不再出现在列表中。存在已发布或审核中 SOP 时,需要先处理对应版本。',
content: '归档后场景及其 SOP 将不再用于新执行。',
okText: '归档场景', okType: 'danger', cancelText: '取消',
async onOk() {
try {
@@ -84,7 +94,7 @@ function archiveScenario() {
function resetFieldForm() {
editingFieldId.value = null
Object.assign(fieldForm, { field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', min: undefined, max: undefined, min_length: undefined, max_length: undefined })
Object.assign(fieldForm, { field_key: '', field_name: '', source_path: '', field_type: 'text', required: false, options_text: '', min: undefined, max: undefined, min_length: undefined, max_length: undefined })
}
function openNewField() {
@@ -96,7 +106,7 @@ function openFieldEditor(field: ScenarioField) {
const validation = field.validation || {}
editingFieldId.value = field.id
Object.assign(fieldForm, {
field_key: field.field_key, field_name: field.field_name, field_type: field.field_type, required: field.required,
field_key: field.field_key, field_name: field.field_name, source_path: field.source_path || '', field_type: field.field_type, required: field.required,
options_text: Array.isArray(field.options) ? field.options.join('\n') : '',
min: typeof validation.min === 'number' ? validation.min : undefined,
max: typeof validation.max === 'number' ? validation.max : undefined,
@@ -135,7 +145,7 @@ async function saveField() {
async function deleteField(field: ScenarioField) {
Modal.confirm({
title: `删除字段“${field.field_name}”?`, content: '已发布或历史 SOP 版本引用的字段不能删除。', okText: '删除字段', okType: 'danger',
title: `删除字段“${field.field_name}”?`, content: '当前 SOP 引用的字段不能删除。', okText: '删除字段', okType: 'danger',
async onOk() {
try { await api.delete(`/scenario-fields/${field.id}`); message.success('字段已删除'); await load() }
catch (error) { message.error(apiMessage(error)) }
@@ -147,7 +157,7 @@ async function createSOP() {
saving.value = true
try {
const data = await api.post<{ sop: SOP }>(`/scenarios/${scenarioId}/sops`, sopForm)
message.success('SOP 草稿已创建')
message.success('SOP 已创建')
sopOpen.value = false
router.push(`/sops/${data.sop.id}`)
} catch (error) {
@@ -157,10 +167,9 @@ async function createSOP() {
}
}
function statusText(value: string) { return ({ draft: '草稿', reviewing: '审核中', published: '已发布', offline: '已下线' } as Record<string, string>)[value] || value }
function fieldTypeText(value: string) { return fieldTypeOptions.find(item => item.value === value)?.label || value }
function visibilityText(value: string) { return ({ tenant: '全企业', team: '团队', private: '仅自己' } as Record<string, string>)[value] || value }
onMounted(load)
onMounted(()=>{load();loadContract()})
</script>
<template>
@@ -179,18 +188,18 @@ onMounted(load)
</div>
</div>
<a-tabs default-active-key="fields" class="detail-tabs">
<a-tab-pane key="fields" tab="场景字段">
<a-tabs v-model:active-key="activeTab" class="detail-tabs">
<a-tab-pane key="fields" tab="场景输入">
<section class="surface">
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息及校验规则</span></div><a-button v-if="auth.can('scenario.edit')" type="primary" @click="openNewField"><PlusOutlined />添加字段</a-button></div>
<a-table :data-source="fields" row-key="id" :pagination="false" :scroll="{x:760}" :columns="[{title:'字段名称',dataIndex:'field_name'},{title:'字段标识',dataIndex:'field_key'},{title:'类型',dataIndex:'field_type',width:130},{title:'必填',dataIndex:'required',width:90},{title:'',key:'action',width:100}]">
<div class="toolbar"><div><b>业务输入字段</b><span class="toolbar-note">定义订单订单商品用户宠物等传入信息及校验规则</span></div><a-button v-if="auth.can('scenario.edit')" type="primary" @click="openNewField"><PlusOutlined />添加输入字段</a-button></div>
<a-table :data-source="fields" row-key="id" :pagination="false" :scroll="{x:920}" :columns="[{title:'字段名称',dataIndex:'field_name'},{title:'字段标识',dataIndex:'field_key'},{title:'外部数据路径',dataIndex:'source_path'},{title:'类型',dataIndex:'field_type',width:130},{title:'必填',dataIndex:'required',width:90},{title:'',key:'action',width:100}]">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'field_key'"><code>{{ record.field_key }}</code></template>
<template v-else-if="column.dataIndex === 'field_type'">{{ fieldTypeText(record.field_type) }}</template>
<template v-else-if="column.dataIndex === 'required'"><a-tag :color="record.required ? 'orange' : 'default'">{{ record.required ? '必填' : '选填' }}</a-tag></template>
<template v-else-if="column.key === 'action'"><div v-if="auth.can('scenario.edit')" class="row-actions"><a-button type="text" aria-label="编辑字段" @click="openFieldEditor(record)"><EditOutlined /></a-button><a-button type="text" danger aria-label="删除字段" @click="deleteField(record)"><DeleteOutlined /></a-button></div></template>
</template>
<template #emptyText><div class="empty-copy">还没有字段添加执行过程中需要收集的客户信息</div></template>
<template #emptyText><div class="empty-copy">还没有输入字段添加 order_order_item_customer_pet_ 等业务字段</div></template>
</a-table>
</section>
</a-tab-pane>
@@ -198,20 +207,30 @@ onMounted(load)
<section class="surface">
<div class="toolbar"><div><b>话术流程</b><span class="toolbar-note">一个场景可以包含多套 SOP</span></div><a-button v-if="auth.can('sop.edit')" type="primary" @click="sopOpen = true"><PlusOutlined />创建 SOP</a-button></div>
<div v-if="sops.length" class="sop-list">
<button v-for="item in sops" :key="item.id" @click="router.push(`/sops/${item.id}`)"><span class="sop-icon"><BranchesOutlined /></span><span class="sop-copy"><b>{{ item.name }}</b><small>{{ item.description || '暂无说明' }}</small></span><a-tag :color="item.status === 'published' ? 'green' : 'default'">{{ statusText(item.status) }}</a-tag><span class="sop-date">{{ new Date(item.updated_at).toLocaleDateString('zh-CN') }}</span></button>
<button v-for="item in sops" :key="item.id" @click="router.push(`/sops/${item.id}`)"><span class="sop-icon"><BranchesOutlined /></span><span class="sop-copy"><b>{{ item.name }}</b><small>{{ item.description || '暂无说明' }}</small></span><span class="sop-date">{{ new Date(item.updated_at).toLocaleDateString('zh-CN') }}</span></button>
</div>
<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>
<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>
</a-tab-pane>
</a-tabs>
</a-skeleton>
<a-modal v-if="auth.can('scenario.edit')" v-model:open="scenarioOpen" title="编辑场景资料" ok-text="保存修改" :confirm-loading="saving" @ok="updateScenario">
<a-form layout="vertical" :model="scenarioForm"><a-form-item label="场景名称"><a-input v-model:value="scenarioForm.name" /></a-form-item><div class="field-grid"><a-form-item label="所属行业"><a-input v-model:value="scenarioForm.industry" /></a-form-item><a-form-item label="适用角色"><a-input v-model:value="scenarioForm.role_name" /></a-form-item></div><a-form-item label="执行目标"><a-textarea v-model:value="scenarioForm.goal" :rows="3" /></a-form-item><a-form-item label="触发条件"><a-textarea v-model:value="scenarioForm.trigger_text" :rows="3" /></a-form-item><a-form-item label="可见范围"><a-radio-group v-model:value="scenarioForm.visibility"><a-radio value="tenant">全企业</a-radio><a-radio value="team">团队</a-radio><a-radio value="private">仅自己</a-radio></a-radio-group></a-form-item></a-form>
</a-modal>
<a-modal v-if="auth.can('scenario.edit')" v-model:open="fieldOpen" :title="fieldModalTitle" :ok-text="editingFieldId ? '保存字段' : '添加字段'" :confirm-loading="saving" @ok="saveField" @cancel="resetFieldForm">
<a-modal v-if="auth.can('scenario.edit')" v-model:open="fieldOpen" :title="fieldModalTitle" :ok-text="editingFieldId ? '保存输入字段' : '添加输入字段'" :confirm-loading="saving" @ok="saveField" @cancel="resetFieldForm">
<a-form layout="vertical" :model="fieldForm">
<div class="field-grid"><a-form-item label="字段名称"><a-input v-model:value="fieldForm.field_name" placeholder="宠物体重" /></a-form-item><a-form-item label="字段标识"><a-input v-model:value="fieldForm.field_key" placeholder="pet_weight" /></a-form-item></div>
<div class="field-grid"><a-form-item label="字段名称"><a-input v-model:value="fieldForm.field_name" placeholder="宠物体重" /></a-form-item><a-form-item label="字段标识"><a-input v-model:value="fieldForm.field_key" placeholder="pet_weight、order_no、customer_name" /></a-form-item></div>
<a-form-item label="外部数据路径"><a-input v-model:value="fieldForm.source_path" placeholder="customer.name 或 order.items[*].product_id" /></a-form-item>
<a-form-item label="字段类型"><a-select v-model:value="fieldForm.field_type" :options="fieldTypeOptions" /></a-form-item>
<a-form-item v-if="['select','multiselect'].includes(fieldForm.field_type)" label="选项(每行一个)"><a-textarea v-model:value="fieldForm.options_text" :rows="4" /></a-form-item>
<div v-if="fieldForm.field_type === 'number'" class="field-grid"><a-form-item label="最小值"><a-input-number v-model:value="fieldForm.min" style="width:100%" /></a-form-item><a-form-item label="最大值"><a-input-number v-model:value="fieldForm.max" style="width:100%" /></a-form-item></div>
@@ -219,12 +238,14 @@ onMounted(load)
<a-checkbox v-model:checked="fieldForm.required">执行时必须填写</a-checkbox>
</a-form>
</a-modal>
<a-modal v-if="auth.can('sop.edit')" v-model:open="sopOpen" title="创建 SOP 草稿" :footer="null">
<a-modal v-if="auth.can('sop.edit')" v-model:open="sopOpen" title="创建 SOP" :footer="null">
<a-form layout="vertical" :model="sopForm" @finish="createSOP"><a-form-item label="SOP 名称" name="name" :rules="[{required:true,message:'请输入 SOP 名称'}]"><a-input v-model:value="sopForm.name" placeholder="问诊问药标准流程" /></a-form-item><a-form-item label="说明"><a-textarea v-model:value="sopForm.description" :rows="4" placeholder="这套流程适用于什么情况" /></a-form-item><a-button type="primary" html-type="submit" block :loading="saving">创建并编排</a-button></a-form>
</a-modal>
</div>
</template>
<style scoped>
.back-link{margin:-4px 0 12px -14px;color:var(--muted)}.detail-heading{min-height:180px;display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding:28px;color:white;background:#202825;border-radius:6px}.scenario-summary{min-width:0}.detail-heading h1{margin:10px 0 8px;font-family:"Noto Serif SC",serif;font-size:28px;letter-spacing:0}.detail-heading p{max-width:720px;margin:0;color:#c3cec9}.heading-tags{display:flex;align-items:center;gap:10px;color:#a8b6b0;font-size:12px}.trigger-copy{max-width:800px;margin-top:14px;color:#aab8b2;font-size:12px}.trigger-copy span{margin-right:9px;color:#68c6a6;font-weight:700}.heading-actions{align-self:stretch;display:flex;flex-direction:column;align-items:flex-end;justify-content:space-between}.heading-actions>div{display:flex;gap:8px}.scenario-code{color:#70817a;font-size:12px}.detail-tabs{margin-top:18px}.toolbar>div{display:flex;align-items:baseline;gap:12px}.toolbar-note{color:var(--muted);font-size:12px}.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.row-actions{display:flex}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.sop-list button{width:100%;min-height:74px;display:grid;grid-template-columns:42px 1fr auto 100px;align-items:center;gap:14px;padding:12px 18px;text-align:left;background:white;border:0;border-bottom:1px solid var(--line);cursor:pointer}.sop-list button:hover{background:#f8faf9}.sop-icon{width:38px;height:38px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.sop-copy{min-width:0;display:flex;flex-direction:column}.sop-copy small{overflow:hidden;margin-top:4px;color:var(--muted);text-overflow:ellipsis;white-space:nowrap}.sop-date{color:var(--muted);font-size:12px;text-align:right}.empty-state{padding:64px 20px;color:#82908a;text-align:center}.empty-state>span{font-size:32px}.empty-state p{margin:12px 0 0}@media(max-width:650px){.detail-heading{align-items:flex-start;flex-direction:column;padding:20px}.heading-actions{width:100%;align-items:flex-start;gap:14px}.scenario-code{display:none}.field-grid{grid-template-columns:1fr;gap:0}.sop-list button{grid-template-columns:36px 1fr auto}.sop-date{display:none}.toolbar-note{display:none}}
.contract-editor{padding:18px}.sdk-identifiers{display:flex;flex-wrap:wrap;gap:18px;margin:12px 0;color:var(--muted);font-size:12px}.contract-json{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.6}
.preview-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px}.preview-input,.preview-output{min-width:0;padding:18px}.preview-output pre{max-height:680px;overflow:auto;margin:0;padding:16px;color:#dbe8e3;background:#202825;border-radius:4px;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.preview-output :deep(.ant-empty){padding:100px 0}
.back-link{margin:-4px 0 12px -14px;color:var(--muted)}.detail-heading{min-height:180px;display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding:28px;color:white;background:#202825;border-radius:6px}.scenario-summary{min-width:0}.detail-heading h1{margin:10px 0 8px;font-family:"Noto Serif SC",serif;font-size:28px;letter-spacing:0}.detail-heading p{max-width:720px;margin:0;color:#c3cec9}.heading-tags{display:flex;align-items:center;gap:10px;color:#a8b6b0;font-size:12px}.trigger-copy{max-width:800px;margin-top:14px;color:#aab8b2;font-size:12px}.trigger-copy span{margin-right:9px;color:#68c6a6;font-weight:700}.heading-actions{align-self:stretch;display:flex;flex-direction:column;align-items:flex-end;justify-content:space-between}.heading-actions>div{display:flex;gap:8px}.scenario-code{color:#70817a;font-size:12px}.detail-tabs{margin-top:18px}.toolbar>div{display:flex;align-items:baseline;gap:12px}.toolbar-note{color:var(--muted);font-size:12px}.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.row-actions{display:flex}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.sop-list button{width:100%;min-height:74px;display:grid;grid-template-columns:42px 1fr 100px;align-items:center;gap:14px;padding:12px 18px;text-align:left;background:white;border:0;border-bottom:1px solid var(--line);cursor:pointer}.sop-list button:hover{background:#f8faf9}.sop-icon{width:38px;height:38px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.sop-copy{min-width:0;display:flex;flex-direction:column}.sop-copy small{overflow:hidden;margin-top:4px;color:var(--muted);text-overflow:ellipsis;white-space:nowrap}.sop-date{color:var(--muted);font-size:12px;text-align:right}.empty-state{padding:64px 20px;color:#82908a;text-align:center}.empty-state>span{font-size:32px}.empty-state p{margin:12px 0 0}@media(max-width:900px){.preview-layout{grid-template-columns:1fr}}@media(max-width:650px){.detail-heading{align-items:flex-start;flex-direction:column;padding:20px}.heading-actions{width:100%;align-items:flex-start;gap:14px}.scenario-code{display:none}.field-grid{grid-template-columns:1fr;gap:0}.sop-list button{grid-template-columns:36px 1fr}.sop-date{display:none}.toolbar-note{display:none}}
</style>