Files
iqudo-top1/web/src/views/RunDetailView.vue
Eric 1549169735@qq.com da3f16e6db update
2026-09-13 20:08:13 +08:00

74 lines
12 KiB
Vue

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