feat: complete pet SOP governance workflow

This commit is contained in:
Eric 1549169735@qq.com
2026-08-08 22:58:35 +08:00
parent 97a76250f7
commit 4b240499c2
85 changed files with 1316 additions and 269 deletions

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { RunDetail, RunEvent, RunFeedback, ScenarioField } from '@/types'
interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];fields:ScenarioField[]}
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 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:'提交回答'}[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}}
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 }} · V{{ detail.run.version }} · {{ 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==='answer'?'blue':event.node_type==='escalate'?'red':'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="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>{{ 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>
</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-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}}
</style>