PlantAI-rag-new 首次提交:文献知识工作台

- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
@@ -0,0 +1,123 @@
<script setup>
// chat/MessageList.vue —— 消息流渲染(FR-CHAT-003/014)
// 自动滚动:新消息/流式更新滚到底;用户手动向上滚动后暂停,
// 显示「回到最新」按钮(FR-CHAT-014);点击或滚回底部恢复。
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { T } from '../../i18n/labels.js';
import UserMessage from './UserMessage.vue';
import AssistantMessage from './AssistantMessage.vue';
const props = defineProps({
messages: { type: Array, required: true },
busy: { type: Boolean, default: false },
reveal: { type: Object, default: null },
scrollSeq: { type: Number, default: 0 }, // chat store 的滚动请求序号
});
const emit = defineEmits(['retry', 'jump-graph', 'jump-source']);
const el = ref(null);
const stickToBottom = ref(true);
const hasMessages = computed(() => props.messages.length > 0);
const onScroll = () => {
const node = el.value;
if (!node) return;
const dist = node.scrollHeight - node.scrollTop - node.clientHeight;
const nearBottom = dist < 60;
if (nearBottom) stickToBottom.value = true;
else stickToBottom.value = false;
};
const scrollToBottom = (smooth = false) => {
const node = el.value;
if (!node) return;
node.scrollTo({ top: node.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
};
const backToLatest = () => {
stickToBottom.value = true;
scrollToBottom(true);
};
watch(
() => [props.messages.length, props.scrollSeq, props.reveal],
() => {
if (stickToBottom.value) scrollToBottom();
},
{ flush: 'post' },
);
onMounted(() => scrollToBottom());
onBeforeUnmount(() => {});
</script>
<template>
<div ref="el" class="chat-scroll" @scroll="onScroll">
<div class="chat-inner">
<template v-for="(m, mi) in messages" :key="m.id ?? mi">
<UserMessage v-if="m.role === 'user'" :message="m" />
<AssistantMessage
v-else
:message="m"
:reveal="reveal"
@retry="emit('retry')"
@jump-graph="emit('jump-graph')"
@jump-source="(payload) => emit('jump-source', payload)"
/>
</template>
</div>
<transition name="back-fade">
<button v-if="hasMessages && !stickToBottom" class="back-latest" type="button" @click="backToLatest">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
{{ T.backToLatest }}
</button>
</transition>
</div>
</template>
<style scoped>
.chat-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
position: relative;
}
.chat-inner {
max-width: 760px;
margin: 0 auto;
padding: 28px 28px 12px;
}
.back-latest {
position: sticky;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--pa-r-pill);
background: var(--pa-raised);
border: 1px solid var(--pa-hairline-strong);
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
z-index: 4;
}
.back-latest:hover {
color: var(--pa-ink);
box-shadow: 0 0 14px rgba(0, 212, 255, 0.18);
}
.back-fade-enter-active,
.back-fade-leave-active {
transition: opacity 0.2s var(--pa-ease);
}
.back-fade-enter-from,
.back-fade-leave-to {
opacity: 0;
}
</style>