Files
PlantAI-rag-new/ui/src/components/chat/FlowFileCard.vue
T
admin 963a87855c feat(bioflow): 生信模块对话式改造 + MCP 外挂化 (S0-S6)
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: 多配置回归 + 对话剧本测试手册
2026-10-11 10:45:25 +08:00

355 lines
10 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/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>