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: 多配置回归 + 对话剧本测试手册
355 lines
10 KiB
Vue
355 lines
10 KiB
Vue
<script setup>
|
||
// chat/FlowFileCard.vue —— 生信数据文件引导卡(FR-BIO-005/006/007,UI_SPEC §8.3 逐字复刻)
|
||
// 缺失提示文案(FR-BIO-005 点名文件)+ 拖放区(border 2px dashed --pa-hairline-strong,
|
||
// r-lg padding 24px 提示 12px ink-subtle 居中;拖入态 border gradient 45% + 背景 gradient 8%)
|
||
// + 点击选择上传(XHR 实时进度:复用 prog-track/prog-fill,旁注百分比 11px mono)
|
||
// + 服务器路径输入行(input 同 8.2 + 「登记路径」pane-btn 同款 12px;Enter 同点按钮)。
|
||
// 上传经 chat.uploadBioflowFileToJob(XHR onUploadProgress);上传中禁重复拖入。
|
||
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 },
|
||
});
|
||
|
||
const emit = defineEmits(['uploaded', 'registered', 'error']);
|
||
|
||
const bioflow = useBioflowStore();
|
||
const chat = useChatStore();
|
||
|
||
const dropActive = ref(false);
|
||
const pathDraft = ref('');
|
||
const registering = ref(false);
|
||
const registerError = ref('');
|
||
const fileInput = ref(null);
|
||
|
||
const jobId = computed(() => bioflow.currentJob.jobId || props.block.jobId || null);
|
||
const files = computed(() => props.block.files || []);
|
||
const uploading = computed(() => files.value.some((f) => f.status === 'uploading'));
|
||
const requiredText = computed(() => {
|
||
const names = (props.block.requiredFiles || []).filter(Boolean);
|
||
return names.length ? BF.fileRequiredTitle.replace('{files}', names.join('、')) : (props.block.message || '');
|
||
});
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes == null) return '';
|
||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${bytes} B`;
|
||
}
|
||
|
||
// ---- 拖放(dragover 高亮 → drop 上传;上传中忽略) ----
|
||
const onDragOver = (e) => {
|
||
if (!jobId.value || uploading.value) return;
|
||
const types = Array.from(e.dataTransfer?.types || []);
|
||
if (types.includes('Files')) {
|
||
e.preventDefault();
|
||
dropActive.value = true;
|
||
}
|
||
};
|
||
const onDragLeave = () => {
|
||
dropActive.value = false;
|
||
};
|
||
const onDrop = (e) => {
|
||
e.preventDefault();
|
||
dropActive.value = false;
|
||
if (!jobId.value || uploading.value) return;
|
||
const list = e.dataTransfer ? e.dataTransfer.files : [];
|
||
handleFiles(list);
|
||
};
|
||
|
||
function handleFiles(fileList) {
|
||
const arr = Array.from(fileList || []).filter((f) => f && typeof f.name === 'string');
|
||
for (const file of arr) {
|
||
chat.uploadBioflowFileToJob(jobId.value, file, {
|
||
onProgress: (pct) => {
|
||
const rec = props.block.files.find((x) => x.name === file.name && x.status === 'uploading');
|
||
if (rec) rec.progress = pct;
|
||
},
|
||
})
|
||
.then((res) => emit('uploaded', { name: file.name, res }))
|
||
.catch((err) => emit('error', err));
|
||
}
|
||
}
|
||
|
||
function onClickPick() {
|
||
if (!jobId.value || uploading.value) return;
|
||
if (fileInput.value) fileInput.value.click();
|
||
}
|
||
|
||
function onInputChange(e) {
|
||
handleFiles(e.target ? e.target.files : []);
|
||
if (e.target) e.target.value = ''; // 允许重复选择同一文件
|
||
}
|
||
|
||
// ---- 服务器路径登记(FR-BIO-007;S3 改走 card_result 进 QA 流) ----
|
||
async function registerPath() {
|
||
const p = pathDraft.value.trim();
|
||
if (!p || registering.value || !jobId.value) return;
|
||
registering.value = true;
|
||
registerError.value = '';
|
||
try {
|
||
await chat.sendCardResult('file_upload', { job_id: jobId.value, paths: [p] });
|
||
pathDraft.value = '';
|
||
emit('registered', { path: p });
|
||
} catch (err) {
|
||
registerError.value = err?.message || BF.filePathError;
|
||
emit('error', err);
|
||
} finally {
|
||
registering.value = false;
|
||
}
|
||
}
|
||
|
||
const onPathKeydown = (e) => {
|
||
if (e.isComposing) return;
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
registerPath();
|
||
} else if (e.key === 'Escape') {
|
||
pathDraft.value = ''; // Esc 清空输入(TC-BIO-ACT-044 两态断言之一)
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flow-file-card">
|
||
<div class="ffile-title">{{ BF.fileCardTitle }}</div>
|
||
<div v-if="requiredText" class="ffile-required">{{ requiredText }}</div>
|
||
|
||
<!-- 拖放区(§8.3) -->
|
||
<div
|
||
class="ffile-drop"
|
||
:class="{ active: dropActive, busy: uploading }"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="onClickPick"
|
||
@dragover="onDragOver"
|
||
@dragleave="onDragLeave"
|
||
@drop="onDrop"
|
||
@keydown.enter.prevent="onClickPick"
|
||
>
|
||
<div class="ffile-drop-hint">
|
||
{{ uploading ? BF.fileDropBusy : BF.fileDropHint }}
|
||
</div>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
class="ffile-input-hidden"
|
||
multiple
|
||
@change="onInputChange"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 上传/登记列表(进度复用 prog-track/prog-fill,旁注百分比 11px mono) -->
|
||
<div v-if="files.length" class="ffile-list">
|
||
<div v-for="(f, i) in files" :key="i" class="ffile-item">
|
||
<span class="ffile-name" :class="f.status">{{ f.name }}</span>
|
||
<span v-if="f.status === 'uploading'" class="ffile-progress">
|
||
<span class="prog-track"><span class="prog-fill" :style="{ width: Math.round((f.progress || 0) * 100) + '%' }"></span></span>
|
||
<span class="ffile-pct">{{ Math.round((f.progress || 0) * 100) }}%</span>
|
||
</span>
|
||
<span v-else-if="f.status === 'done'" class="ffile-done">
|
||
<span class="ffile-size">{{ formatSize(f.size) }}</span>
|
||
<span class="ffile-tag">{{ f.source === 'server_path' ? BF.fileRegistered : BF.fileUploadDone }}</span>
|
||
</span>
|
||
<span v-else class="ffile-err" :title="f.error">{{ BF.fileUploadError }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 服务器路径输入行(§8.3:input 同 8.2 + 「登记路径」pane-btn 同款 12px) -->
|
||
<div class="ffile-path-row">
|
||
<input
|
||
v-model="pathDraft"
|
||
class="ffile-path-input"
|
||
type="text"
|
||
:placeholder="BF.fileRegisterPlaceholder"
|
||
:disabled="registering"
|
||
@keydown="onPathKeydown"
|
||
/>
|
||
<button
|
||
class="pane-btn"
|
||
type="button"
|
||
:disabled="!pathDraft.trim() || registering"
|
||
@click="registerPath"
|
||
>{{ registering ? BF.fileRegistering : BF.fileRegister }}</button>
|
||
</div>
|
||
<div v-if="registerError" class="ffile-error">{{ registerError }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* UI_SPEC §8.3 逐字复刻 */
|
||
.flow-file-card {
|
||
margin: 14px 0;
|
||
padding: 14px 16px;
|
||
background: var(--pa-surface-1);
|
||
border: 1px solid var(--pa-hairline);
|
||
border-radius: var(--pa-r-lg);
|
||
}
|
||
.ffile-title {
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.3px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.ffile-required {
|
||
font-size: 12px;
|
||
color: var(--pa-ink-muted);
|
||
margin-bottom: 10px;
|
||
line-height: 1.6;
|
||
}
|
||
/* 拖放区:border 2px dashed --pa-hairline-strong,r-lg,padding 24px,居中提示 12px ink-subtle */
|
||
.ffile-drop {
|
||
border: 2px dashed var(--pa-hairline-strong);
|
||
border-radius: var(--pa-r-lg);
|
||
padding: 24px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.18s var(--pa-ease), background 0.18s var(--pa-ease);
|
||
}
|
||
/* 拖入态:border-color gradient 45% + 背景 gradient 8% */
|
||
.ffile-drop.active {
|
||
border-color: color-mix(in srgb, var(--pa-g-from) 45%, transparent);
|
||
background: color-mix(in srgb, var(--pa-g-from) 8%, transparent);
|
||
}
|
||
.ffile-drop.busy {
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
}
|
||
.ffile-drop-hint {
|
||
font-size: 12px;
|
||
color: var(--pa-ink-subtle);
|
||
}
|
||
.ffile-input-hidden {
|
||
display: none;
|
||
}
|
||
/* 进度条:复用 prog-track/prog-fill(track surface-2 r-pill 高 4px;fill --pa-gradient) */
|
||
.ffile-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
}
|
||
.ffile-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
}
|
||
.ffile-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: var(--pa-ink-muted);
|
||
font-family: var(--pa-font-mono);
|
||
}
|
||
.ffile-name.done { color: var(--pa-ink); }
|
||
.ffile-name.error { color: var(--pa-error); }
|
||
.ffile-progress {
|
||
flex: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
width: 120px;
|
||
}
|
||
.prog-track {
|
||
flex: 1;
|
||
height: 4px;
|
||
border-radius: var(--pa-r-pill);
|
||
background: var(--pa-surface-2);
|
||
overflow: hidden;
|
||
}
|
||
.prog-fill {
|
||
display: block;
|
||
height: 100%;
|
||
background: var(--pa-gradient);
|
||
transition: width 0.15s var(--pa-ease);
|
||
}
|
||
/* 旁注百分比 11px mono */
|
||
.ffile-pct {
|
||
font-size: 11px;
|
||
font-family: var(--pa-font-mono);
|
||
color: var(--pa-ink-subtle);
|
||
min-width: 34px;
|
||
text-align: right;
|
||
}
|
||
.ffile-done {
|
||
flex: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.ffile-size {
|
||
font-size: 11px;
|
||
font-family: var(--pa-font-mono);
|
||
color: var(--pa-ink-subtle);
|
||
}
|
||
.ffile-tag {
|
||
font-size: 11px;
|
||
color: var(--pa-success);
|
||
}
|
||
.ffile-err {
|
||
flex: none;
|
||
font-size: 11px;
|
||
color: var(--pa-error);
|
||
}
|
||
/* 路径输入行(§8.3:input 同 8.2 + 登记路径 pane-btn 同款 12px) */
|
||
.ffile-path-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
.ffile-path-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
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);
|
||
}
|
||
.ffile-path-input:focus {
|
||
border-color: var(--pa-g-from);
|
||
}
|
||
.ffile-path-input::placeholder {
|
||
color: var(--pa-ink-subtle);
|
||
}
|
||
.ffile-path-input:disabled {
|
||
opacity: 0.5;
|
||
}
|
||
.pane-btn {
|
||
flex: none;
|
||
padding: 6px 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;
|
||
}
|
||
.ffile-error {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: var(--pa-error);
|
||
}
|
||
</style>
|