Files
PlantAI-rag-new/ui/src/components/chat/FlowPathCard.vue
T
admin 7840527f82 fix(bioflow): 路径卡确认创建后事件断链——任务 201 创建但 UI 无反馈
根因:FlowPathCard.confirmCreate 创建成功后 emit(updated) 全链无人监听
(与 r3-07-01 跳过按钮同模式的事件断链,基线即存在)。后果:无进度卡、
无任务事件订阅、不落库(刷新即丢 jobId)。

修复:chat store 新增 confirmBioflowPath 一站式 action(创建→挂 job_created
进度块→connectJobEvents 带 onEvent 桥接推进消息块→落库);FlowPathCard
改调 action 并防重复创建;hydrateDetail 恢复未终态任务的订阅(刷新后续流转);
connectJobEvents 加 onEvent 回调。单测 5 项新增,全套 373 全绿。
2026-09-10 21:14:24 +08:00

364 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// chat/FlowPathCard.vue —— 生信分析路径选择卡(FR-BIO-002/003/004,UI_SPEC §8.1 逐字复刻)
// 候选路径行(阶段序列首阶段加粗、hover surface-3、选中态 1.5px --pa-g-from + 8% 混色底)
// 「确认此路径」--pa-gradient 白字 12.5px/600,无选中禁用 opacity .45。
// 确认后切换为确认视图(FR-BIO-004:逐阶段名称/用途/预计耗时),点「确认创建任务」
// 发出 POST /api/bioflow/jobs。
// 复用句式(FR-BIO-023):候选来自存档装载,卡片头部明示「复用存档 XXX 的方法」。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useBioflowStore } from '../../stores/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
message: { type: Object, default: null },
});
const emit = defineEmits(['flow-error', 'updated']);
const selected = computed({
get: () => (typeof props.block.selected === 'number' ? props.block.selected : null),
set: (v) => { props.block.selected = v; },
});
const candidates = computed(() => props.block.candidates || []);
const autoFilled = computed(() => props.block.autoFilled || []);
const reuseName = computed(() => props.block.reuseName || null);
const reuseArchive = computed(() => props.block.reuseArchive || null);
// 确认视图(FR-BIO-004):选中候选 → 逐阶段三要素
const view = ref('select'); // select | confirm
const confirming = ref(false);
const error = ref('');
const chat = useChatStore();
const bioflow = useBioflowStore();
const confirmLabel = computed(() => (confirming.value ? BF.pathStageCreating : BF.pathStageCreate));
const canConfirm = computed(() => selected.value !== null && !confirming.value);
const selectedCandidate = computed(() => {
if (selected.value === null) return null;
return candidates.value[selected.value] || null;
});
function pick(i) {
selected.value = i;
view.value = 'select';
error.value = '';
}
function toConfirm() {
if (selected.value === null || confirming.value) return;
view.value = 'confirm';
}
function backToSelect() {
if (confirming.value) return;
view.value = 'select';
}
/** 复用名 → 表单默认值来源标注(与后端 params 预填配合;来源标注在表单卡展示) */
async function confirmCreate() {
const cand = selectedCandidate.value;
if (!cand || confirming.value) return;
if (props.block.jobId) return; // 已创建过(防重复创建;FR-BIO 任务幂等语义)
confirming.value = true;
error.value = '';
try {
// r4 修复:不再 emit('updated')(该事件全链无人监听,任务创建后 UI 静默)——
// 改走 chat store 一站式 confirmBioflowPath:创建 → 挂进度块 → 订阅任务事件
// 桥接推进 → 落库。aMsg 从 chat 消息流定位(当前消息 = 含本 path 块的 assistant 消息)。
const aMsg = (chat.messages || []).find(
(m) => m && m.role === 'assistant' && Array.isArray(m.blocks) && m.blocks.includes(props.block),
);
const detail = await chat.confirmBioflowPath({
title: (chat.messages || []).find((m) => m.role === 'user')?.text?.slice(0, 50) || '生信任务',
stages: cand.stages,
params: {},
autoContinue: false,
archiveId: reuseArchive.value || null,
aMsg: aMsg || null,
});
// 进度块由 confirmBioflowPath 挂载;此处标记 path 块已确认
props.block.jobId = (detail && detail.id) || null;
} catch (err) {
error.value = err?.message || BF.pathStageCreating;
emit('flow-error', err);
} finally {
confirming.value = false;
}
}
/** 阶段耗时展示(catalog 契约无 estimated_time 时按规格兜底文案) */
function stageTime(stage) {
return stage && stage.estimated_time ? stage.estimated_time : BF.pathStageEstimatedUnknown;
}
defineExpose({ select: pick, confirm: confirmCreate });
</script>
<template>
<div class="flow-path-card">
<div class="fpc-head">
<span class="fpc-title">{{ BF.pathCardTitle }}</span>
<span v-if="reuseName" class="badge cap">
{{ BF.pathReuseNote }}「{{ reuseName }}」{{ BF.pathReuseNoteSuffix }}
</span>
</div>
<!-- 选择视图(FR-BIO-002/003) -->
<template v-if="view === 'select'">
<div v-if="!candidates.length" class="fpc-empty">{{ BF.pathEmpty }}</div>
<div
v-for="(c, i) in candidates"
:key="i"
class="fpc-row"
:class="{ selected: selected === i }"
role="button"
tabindex="0"
@click="pick(i)"
@dblclick="pick(i)"
@keydown.enter.prevent="pick(i)"
>
<div class="fpc-stages">
<template v-for="(st, si) in c.stages" :key="st">
<span class="stage" :class="{ first: si === 0 }">{{ st }}</span>
<span v-if="si < c.stages.length - 1" class="arrow"> → </span>
</template>
<span
v-for="st in autoFilled.filter((x) => !c.stages.includes(x))"
:key="'af-' + st"
class="stage autofill"
>{{ BF.pathAutoFilled }} {{ st }}</span>
</div>
<div v-if="c.purpose" class="fpc-purpose">{{ BF.pathCandidatePurposeLabel }}:{{ c.purpose }}</div>
</div>
<!-- 规划降级明示(内置兜底链;失败必须可见) -->
<div v-if="block.fallback" class="fpc-fallback">{{ BF.pathFallback }}{{ block.fallback }}</div>
<div class="fpc-actions">
<button
class="fpc-confirm"
type="button"
:disabled="selected === null"
@click="toConfirm"
@keydown.enter.prevent="toConfirm"
>{{ BF.pathConfirm }}</button>
</div>
</template>
<!-- 确认视图(FR-BIO-004:逐阶段名称/用途/预计耗时) -->
<template v-else>
<div class="fpc-confirm-head">{{ BF.pathStageTitle }}</div>
<div class="fpc-confirm-hint">{{ BF.pathStageHint }}</div>
<div class="fpc-confirm-stages">
<div v-for="(st, si) in selectedCandidate.stages" :key="st" class="fpc-cs-row">
<span class="fpc-cs-idx">{{ si + 1 }}</span>
<span class="fpc-cs-name">{{ st }}</span>
<span class="fpc-cs-time">{{ stageTime(null) }}</span>
</div>
</div>
<div class="fpc-actions">
<button class="pane-btn" type="button" :disabled="confirming" @click="backToSelect">{{ BF.jobsConfirmNo }}</button>
<button
class="fpc-confirm"
type="button"
:disabled="!canConfirm"
@click="confirmCreate"
>{{ confirmLabel }}</button>
</div>
<div v-if="error" class="fpc-error">{{ error }}</div>
</template>
</div>
</template>
<style scoped>
/* UI_SPEC §8.1 逐字复刻:卡体 surface-1 + hairline,r-lg,padding 14px 16px,margin 14px 0 */
.flow-path-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.fpc-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.fpc-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
}
.badge.cap {
color: var(--pa-g-to);
background: color-mix(in srgb, var(--pa-g-from) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--pa-g-from) 35%, transparent);
padding: 2px 10px;
border-radius: var(--pa-r-pill);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.5px;
white-space: nowrap;
}
.fpc-empty {
padding: 10px 0;
font-size: 12px;
color: var(--pa-ink-subtle);
}
/* 候选路径行:padding 8px 10px,r-md,hover surface-3 */
.fpc-row {
padding: 8px 10px;
border-radius: var(--pa-r-md);
border: 1.5px solid transparent;
cursor: pointer;
transition: background 0.18s var(--pa-ease), border-color 0.18s var(--pa-ease);
}
.fpc-row:hover {
background: var(--pa-surface-3);
}
/* 选中态:border 1.5px --pa-g-from + 背景 color-mix(g-from 8%) */
.fpc-row.selected {
border-color: var(--pa-g-from);
background: color-mix(in srgb, var(--pa-g-from) 8%, transparent);
}
.fpc-stages {
font-size: 12px;
color: var(--pa-ink-muted);
line-height: 1.6;
word-break: break-all;
}
.fpc-stages .stage.first {
font-weight: 600;
color: var(--pa-ink);
}
.fpc-stages .arrow {
margin: 0 2px;
color: var(--pa-ink-subtle);
}
.fpc-stages .stage.autofill {
color: var(--pa-g-to);
}
.fpc-purpose {
margin-top: 2px;
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.fpc-fallback {
margin-top: 8px;
font-size: 11.5px;
color: var(--pa-warning);
}
.fpc-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
/* 确认按钮:--pa-gradient 底白字 12.5px/600,r-md padding 7px 14px;禁用 opacity .45 */
.fpc-confirm {
padding: 7px 14px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s var(--pa-ease), transform 0.15s var(--pa-ease), opacity 0.2s var(--pa-ease);
}
.fpc-confirm:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.fpc-confirm:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.pane-btn {
padding: 7px 13px;
border-radius: var(--pa-r-md);
border: 1px solid var(--pa-hairline);
background: var(--pa-surface-2);
color: var(--pa-ink-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.18s var(--pa-ease);
}
.pane-btn:hover:not(:disabled) {
color: var(--pa-ink);
background: var(--pa-surface-3);
}
.pane-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 确认视图(FR-BIO-004) */
.fpc-confirm-head {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
.fpc-confirm-hint {
font-size: 12px;
color: var(--pa-ink-muted);
margin-bottom: 8px;
}
.fpc-confirm-stages {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 6px;
}
.fpc-cs-row {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 6px;
border-radius: var(--pa-r-sm);
font-size: 12px;
}
.fpc-cs-row:hover {
background: var(--pa-surface-3);
}
.fpc-cs-idx {
flex: none;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--pa-surface-3);
color: var(--pa-ink-subtle);
font-family: var(--pa-font-mono);
font-size: 11px;
}
.fpc-cs-name {
flex: 1;
min-width: 0;
font-family: var(--pa-font-mono);
font-size: 12px;
color: var(--pa-ink);
}
.fpc-cs-time {
flex: none;
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.fpc-error {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
</style>