S0: bioflow_service 三 MCP 端点(gene-family/rnaseq/extension) + 源文件获取/预处理 S1: QA bioflow_agent 前门 router + capabilities.yaml 三条目 + 熔断 S2: card_result 回填 + 多轮记忆 + session↔job 绑定 S3: 前端 TAB 双信号 + 卡片辅助化 + 进度块只读 + 删除直连 S4: 扩展子系统对话化(第三能力单元) S5: toggle_bioflow.sh 装卸 + SettingsPanel 三开关 S6: 多配置回归 + 对话剧本测试手册
415 lines
14 KiB
Vue
415 lines
14 KiB
Vue
<script setup>
|
||
// chat/FlowFormCard.vue —— 生信参数表单卡(FR-BIO-008/009/010,UI_SPEC §8.2 逐字复刻)
|
||
// 标题「⚙ XXXX 步骤 · 可调整参数」12.5px/600/ls .3px;前置文案(FR-BIO-008 对话告知)
|
||
// 字段行:label 12px ink-muted 宽 96px 固定;input surface-1 + hairline,r-sm,
|
||
// padding 6px 10px,focus border --pa-g-from;来源标注每字段下方 11px ink-subtle
|
||
// (契约文档/参数预填/存档名三来源)
|
||
// 继续按钮「继续」--pa-gradient 白字 12.5px/600 r-md padding 7px 16px
|
||
// 自动连续开关「自动连续执行」12px ink-muted + mode-pill 同款开关(FR-BIO-010)
|
||
// 校验:数字类型/choices/min-max;Esc 恢复默认值;Enter 提交;提交中禁用+防重复。
|
||
import { computed, reactive, 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 },
|
||
});
|
||
|
||
const emit = defineEmits(['submitted', 'error']);
|
||
|
||
const bioflow = useBioflowStore();
|
||
const chat = useChatStore();
|
||
|
||
const fields = computed(() => props.block.fields || []);
|
||
const stage = computed(() => props.block.stage || '');
|
||
const rerunMode = computed(() => !!props.block.rerun);
|
||
const reuseMode = computed(() => props.block.mode === 'reuse'); // FR-BIO-023 复用预填表单
|
||
|
||
// 编辑值副本(Esc 恢复默认值用;提交时按 name 收集)
|
||
const values = reactive({});
|
||
const errors = reactive({});
|
||
const submitting = ref(false);
|
||
const submitError = ref('');
|
||
|
||
function syncDefaults() {
|
||
for (const f of fields.value) {
|
||
if (!(f.name in values)) values[f.name] = f.default ?? '';
|
||
}
|
||
}
|
||
syncDefaults();
|
||
|
||
/** 自动连续开关(FR-BIO-010):任务级标志存 bioflow store;
|
||
* 开启后事件流在后续 stage_await_confirm 自动按默认值 confirm(见 chat.sendBioflow) */
|
||
const autoContinue = computed({
|
||
get: () => bioflow.currentJob.autoContinue,
|
||
set: (v) => { bioflow.currentJob.autoContinue = !!v; },
|
||
});
|
||
|
||
/** 来源标注(契约文档/参数预填/存档名;source=archive 时显示存档名) */
|
||
function sourceLabel(f) {
|
||
const src = f.source || 'contract';
|
||
if (src === 'archive') return BF.formSourceArchive.replace('{name}', f.archiveName || '');
|
||
if (src === 'prefill' || src === 'runtime') return BF.formSourcePrefill;
|
||
if (src === 'user') return BF.formSourceUser;
|
||
if (src === 'contract') return BF.formSourceContract;
|
||
return BF.formSourceUnknown;
|
||
}
|
||
|
||
/** 复用预填模式:值从块 prefill 读取({value, source, archiveName}),直接可编辑提交 */
|
||
function displayValue(f) {
|
||
if (!reuseMode.value) return values[f.name];
|
||
const pre = (props.block.prefill && props.block.prefill[f.name]) || null;
|
||
return pre && typeof pre === 'object' ? pre.value : (pre ?? f.default ?? '');
|
||
}
|
||
function setDisplayValue(f, v) {
|
||
if (!reuseMode.value) {
|
||
values[f.name] = v;
|
||
return;
|
||
}
|
||
const pre = (props.block.prefill && props.block.prefill[f.name]) || null;
|
||
const meta = pre && typeof pre === 'object' ? pre : { value: pre, source: 'archive' };
|
||
props.block.prefill = { ...(props.block.prefill || {}), [f.name]: { ...meta, value: v } };
|
||
}
|
||
function displaySource(f) {
|
||
if (!reuseMode.value) return sourceLabel(f);
|
||
const pre = (props.block.prefill && props.block.prefill[f.name]) || null;
|
||
const meta = pre && typeof pre === 'object' ? pre : null;
|
||
if (meta && meta.source === 'archive' && meta.archiveName) {
|
||
return BF.formSourceArchive.replace('{name}', meta.archiveName);
|
||
}
|
||
if (meta && meta.source === 'archive') return BF.formSourceArchive.replace('{name}', '');
|
||
return sourceLabel({ ...f, source: (meta && meta.source) || f.source || 'contract' });
|
||
}
|
||
|
||
function fieldValue(f) {
|
||
return values[f.name];
|
||
}
|
||
|
||
/** 校验:数字类型 + min/max + choices(TC-BIO-FORM-014/016;不合法不发请求) */
|
||
function validateField(f) {
|
||
const v = reuseMode.value ? displayValue(f) : values[f.name];
|
||
if (v === '' || v === null || v === undefined) return ''; // 空值交给后端按默认处理
|
||
const type = f.type || 'string';
|
||
if (type === 'int' || type === 'float' || type === 'number') {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return BF.formTypeError;
|
||
if (f.min != null && n < f.min) return BF.formRangeError.replace('{min}', f.min).replace('{max}', f.max ?? '');
|
||
if (f.max != null && n > f.max) return BF.formRangeError.replace('{min}', f.min ?? '').replace('{max}', f.max);
|
||
}
|
||
if (Array.isArray(f.choices) && f.choices.length && !f.choices.some((c) => String(c) === String(v))) {
|
||
return BF.formTypeError;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function validateAll() {
|
||
let ok = true;
|
||
for (const f of fields.value) {
|
||
const err = validateField(f);
|
||
errors[f.name] = err;
|
||
if (err) ok = false;
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
/** 收集提交参数:类型归一(数字→Number,与后端参数校验一致) */
|
||
function collectParams() {
|
||
const params = {};
|
||
for (const f of fields.value) {
|
||
const v = reuseMode.value ? displayValue(f) : values[f.name];
|
||
if (v === '' || v === null || v === undefined) continue; // 可选留空不发送
|
||
if (f.type === 'int') params[f.name] = parseInt(v, 10);
|
||
else if (f.type === 'float' || f.type === 'number') params[f.name] = parseFloat(v);
|
||
else if (f.type === 'bool' || f.type === 'boolean') params[f.name] = v === true || v === 'true';
|
||
else params[f.name] = String(v);
|
||
}
|
||
return params;
|
||
}
|
||
|
||
/** 继续:确认阶段(FR-BIO-009);改参数重跑场景走 rerun 带参数(FR-BIO-012);
|
||
* 复用预填模式(FR-BIO-023)创建按存档参数发起的任务,不确认阶段 */
|
||
async function submit() {
|
||
if (submitting.value) return;
|
||
if (!validateAll()) return;
|
||
submitting.value = true;
|
||
submitError.value = '';
|
||
const params = collectParams();
|
||
try {
|
||
if (reuseMode.value) {
|
||
// 复用发起:POST archives/{id}/run(后端已合并存档参数)
|
||
const archiveId = props.block.archiveId;
|
||
if (!archiveId) throw new Error('no archive');
|
||
const detail = await bioflow.runArchive(archiveId, {
|
||
sessionId: props.block.sessionId || '',
|
||
target: props.block.target || '',
|
||
params,
|
||
});
|
||
props.block.jobId = detail.id || null;
|
||
props.block.submitted = true;
|
||
emit('submitted', { stage: stage.value, params, reuse: true, jobId: detail.id });
|
||
} else if (rerunMode.value) {
|
||
await bioflow.rerunStage(stage.value, params);
|
||
props.block.submitted = true;
|
||
emit('submitted', { stage: stage.value, params });
|
||
} else {
|
||
const jobId = bioflow.currentJob.jobId || props.block.jobId;
|
||
if (!jobId) throw new Error('no job');
|
||
// S3:卡片不直连控制面——参数确认改为 card_result 消息进 QA 流(BioflowAgent 经 confirm_stage 执行)
|
||
await chat.sendCardResult('form_confirm', { job_id: jobId, stage: stage.value, params });
|
||
props.block.submitted = true;
|
||
emit('submitted', { stage: stage.value, params });
|
||
}
|
||
} catch (err) {
|
||
submitError.value = err?.message || BF.formSubmitError;
|
||
emit('error', err);
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
}
|
||
|
||
/** Esc 恢复默认值(TC-BIO-FORM-024:放弃编辑两态断言之一) */
|
||
function onEsc() {
|
||
if (reuseMode.value) return; // 复用模式预填值不改回(档存值即默认)
|
||
for (const f of fields.value) values[f.name] = f.default ?? '';
|
||
}
|
||
|
||
/** Enter 触发继续(TC-BIO-FORM-023;输入法组合键不触发) */
|
||
function onKeydown(e, f) {
|
||
if (e.isComposing) return;
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
submit();
|
||
} else if (e.key === 'Escape') {
|
||
onEsc();
|
||
}
|
||
}
|
||
|
||
// 复用模式标题与按钮文案(FR-BIO-023)
|
||
const titleText = computed(() => {
|
||
if (reuseMode.value) {
|
||
const name = (props.block.archiveName || '').replace(/\{name\}/g, '');
|
||
return BF.reuseFormTitle.replace('{name}', name || props.block.archiveName || '');
|
||
}
|
||
return BF.formTitle.replace('{stage}', stage.value);
|
||
});
|
||
const continueText = computed(() => {
|
||
if (submitting.value) return BF.formSubmitting;
|
||
if (reuseMode.value) return props.block.confirming ? BF.reuseFormCreating : BF.reuseFormCreate;
|
||
return BF.formContinue;
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flow-form-card">
|
||
<div class="ffc-title">{{ titleText }}</div>
|
||
<div v-if="reuseMode" class="ffc-prefix">{{ BF.reuseFormHint }}</div>
|
||
<div v-else class="ffc-prefix">{{ BF.formPrefix.replace('{stage}', stage) }}</div>
|
||
|
||
<div v-for="f in fields" :key="f.name" class="ffc-field">
|
||
<label class="ffc-label" :for="'bioflow-' + stage + '-' + f.name">{{ f.name }}</label>
|
||
<div class="ffc-input-wrap">
|
||
<select
|
||
v-if="Array.isArray(f.choices) && f.choices.length"
|
||
:id="'bioflow-' + stage + '-' + f.name"
|
||
:value="displayValue(f)"
|
||
class="ffc-input"
|
||
:disabled="submitting"
|
||
@change="(e) => setDisplayValue(f, e.target.value)"
|
||
>
|
||
<option v-for="c in f.choices" :key="c" :value="c">{{ c }}</option>
|
||
</select>
|
||
<input
|
||
v-else
|
||
:id="'bioflow-' + stage + '-' + f.name"
|
||
:value="displayValue(f)"
|
||
class="ffc-input"
|
||
:class="{ invalid: errors[f.name] }"
|
||
:type="f.type === 'int' || f.type === 'float' || f.type === 'number' ? 'text' : 'text'"
|
||
:disabled="submitting"
|
||
@input="(e) => setDisplayValue(f, e.target.value)"
|
||
@keydown="(e) => onKeydown(e, f)"
|
||
/>
|
||
</div>
|
||
<div class="ffc-meta">
|
||
<span class="ffc-source">{{ displaySource(f) }}</span>
|
||
<span v-if="errors[f.name]" class="ffc-err">{{ errors[f.name] }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ffc-footer">
|
||
<label class="ffc-auto">
|
||
<span class="ffc-auto-label">{{ BF.formAutoLabel }}</span>
|
||
<button
|
||
type="button"
|
||
class="mode-pill"
|
||
:class="{ on: autoContinue }"
|
||
:aria-pressed="autoContinue"
|
||
@click="autoContinue = !autoContinue"
|
||
@keydown.enter.prevent="autoContinue = !autoContinue"
|
||
>{{ autoContinue ? 'ON' : 'OFF' }}</button>
|
||
</label>
|
||
<button
|
||
v-if="!props.block.submitted"
|
||
class="ffc-continue"
|
||
type="button"
|
||
:disabled="submitting"
|
||
@click="submit"
|
||
>{{ continueText }}</button>
|
||
<span v-else class="ffc-confirmed">{{ BF.formConfirmed }}</span>
|
||
</div>
|
||
<div v-if="submitError" class="ffc-submit-err">{{ submitError }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* UI_SPEC §8.2 逐字复刻 */
|
||
.flow-form-card {
|
||
margin: 14px 0;
|
||
padding: 14px 16px;
|
||
background: var(--pa-surface-1);
|
||
border: 1px solid var(--pa-hairline);
|
||
border-radius: var(--pa-r-lg);
|
||
}
|
||
.ffc-title {
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.3px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.ffc-prefix {
|
||
font-size: 12px;
|
||
color: var(--pa-ink-muted);
|
||
margin-bottom: 10px;
|
||
}
|
||
.ffc-field {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
/* label 12px ink-muted 宽 96px 固定 */
|
||
.ffc-label {
|
||
flex: none;
|
||
width: 96px;
|
||
padding-top: 7px;
|
||
font-size: 12px;
|
||
color: var(--pa-ink-muted);
|
||
font-family: var(--pa-font-mono);
|
||
word-break: break-all;
|
||
}
|
||
.ffc-input-wrap {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
/* input surface-1 + hairline,r-sm,padding 6px 10px,focus border --pa-g-from */
|
||
.ffc-input {
|
||
width: 100%;
|
||
padding: 6px 10px;
|
||
background: var(--pa-surface-1);
|
||
border: 1px solid var(--pa-hairline);
|
||
border-radius: var(--pa-r-sm);
|
||
color: var(--pa-ink);
|
||
font-size: 12.5px;
|
||
font-family: var(--pa-font-mono);
|
||
outline: none;
|
||
transition: border-color 0.18s var(--pa-ease);
|
||
}
|
||
.ffc-input:focus {
|
||
border-color: var(--pa-g-from);
|
||
}
|
||
.ffc-input.invalid {
|
||
border-color: var(--pa-error);
|
||
}
|
||
.ffc-input:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
/* 来源标注:每字段下方 11px ink-subtle */
|
||
.ffc-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-top: 3px;
|
||
}
|
||
.ffc-source {
|
||
font-size: 11px;
|
||
color: var(--pa-ink-subtle);
|
||
}
|
||
.ffc-err {
|
||
font-size: 11px;
|
||
color: var(--pa-error);
|
||
}
|
||
.ffc-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-top: 4px;
|
||
}
|
||
/* 自动连续开关(FR-BIO-010):12px ink-muted + mode-pill 同款开关 */
|
||
.ffc-auto {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
}
|
||
.ffc-auto-label {
|
||
font-size: 12px;
|
||
color: var(--pa-ink-muted);
|
||
}
|
||
.mode-pill {
|
||
padding: 4px 12px;
|
||
border-radius: var(--pa-r-pill);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--pa-ink-muted);
|
||
border: 1px solid var(--pa-hairline);
|
||
background: none;
|
||
transition: all 0.2s var(--pa-ease);
|
||
cursor: pointer;
|
||
}
|
||
.mode-pill:hover {
|
||
color: var(--pa-ink);
|
||
background: var(--pa-surface-2);
|
||
}
|
||
.mode-pill.on {
|
||
color: #fff;
|
||
background: var(--pa-gradient);
|
||
border-color: transparent;
|
||
box-shadow: 0 0 12px rgba(0, 212, 255, 0.3);
|
||
}
|
||
/* 继续按钮:「继续」--pa-gradient 白字 12.5px/600,r-md padding 7px 16px */
|
||
.ffc-continue {
|
||
padding: 7px 16px;
|
||
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);
|
||
}
|
||
.ffc-continue:hover:not(:disabled) {
|
||
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
|
||
transform: translateY(-1px);
|
||
}
|
||
.ffc-continue:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
box-shadow: none;
|
||
transform: none;
|
||
}
|
||
.ffc-confirmed {
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
color: var(--pa-ink-subtle);
|
||
}
|
||
.ffc-submit-err {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: var(--pa-error);
|
||
}
|
||
</style>
|