forked from admin/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>
141 lines
4.5 KiB
Vue
141 lines
4.5 KiB
Vue
<!-- SourceRow.vue —— 文献来源单条(B3 归属)
|
||
FR-SOURCE-001/002/003:mono 列表视觉、字段缺失不渲染占位、外链优先级 url>doi>pmid>pmcid。 -->
|
||
<script setup>
|
||
import { computed, ref, watch } from 'vue';
|
||
import { T } from '../../i18n/labels.js';
|
||
|
||
const props = defineProps({
|
||
source: { type: Object, required: true },
|
||
index: { type: Number, default: 0 },
|
||
count: { type: Number, default: null }, // 会话级聚合的出现次数
|
||
highlighted: { type: Boolean, default: false }, // FR-CHAT-015:引用 chip 定位高亮
|
||
});
|
||
|
||
const expanded = ref(false);
|
||
const rootEl = ref(null);
|
||
|
||
// 高亮激活时滚动到可视区(FR-CHAT-015 滚动定位)
|
||
watch(
|
||
() => props.highlighted,
|
||
(on) => {
|
||
if (on && rootEl.value) {
|
||
rootEl.value.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
}
|
||
},
|
||
);
|
||
|
||
/** 外链优先级:url > doi > pmid > pmcid;均无 → null(不显示链接图标,FR-SOURCE-003) */
|
||
const linkHref = computed(() => {
|
||
const s = props.source || {};
|
||
if (s.url) return s.url;
|
||
if (s.doi) return `https://doi.org/${s.doi}`;
|
||
if (s.pmid) return `https://pubmed.ncbi.nlm.nih.gov/${s.pmid}/`;
|
||
if (s.pmcid) return `https://www.ncbi.nlm.nih.gov/pmc/articles/${s.pmcid}/`;
|
||
return null;
|
||
});
|
||
|
||
const metaParts = computed(() => {
|
||
const s = props.source || {};
|
||
const parts = [];
|
||
if (s.pmid) parts.push(`PMID:${s.pmid}`);
|
||
if (s.doi) parts.push(`DOI:${s.doi}`);
|
||
if (s.score != null) parts.push(`${T.score} ${Number(s.score).toFixed(2)}`);
|
||
if (props.count != null && props.count > 1) parts.push(`×${props.count}`);
|
||
return parts;
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="rootEl" class="ft-item source-row" :class="{ 'hl-active': highlighted }">
|
||
<span class="ft-ico idx">{{ index + 1 }}</span>
|
||
<div class="src-main">
|
||
<div class="src-line1">
|
||
<span class="ft-name" :title="source.title || ''">{{ source.title }}</span>
|
||
</div>
|
||
<div class="ft-size src-meta">{{ metaParts.join(' · ') }}</div>
|
||
<div v-if="source.snippet" class="src-snippet" :class="{ clamp: !expanded }" @click="expanded = !expanded">
|
||
{{ source.snippet }}
|
||
</div>
|
||
</div>
|
||
<a
|
||
v-if="linkHref"
|
||
class="ft-dl"
|
||
:href="linkHref"
|
||
target="_blank"
|
||
rel="noopener"
|
||
:title="linkHref"
|
||
@click.stop
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||
<path d="M15 3h6v6" />
|
||
<path d="M10 14L21 3" />
|
||
</svg>
|
||
</a>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.source-row {
|
||
padding: 6px 8px;
|
||
border-radius: var(--pa-r-sm);
|
||
font-family: var(--pa-font-mono);
|
||
font-size: 12.5px;
|
||
transition: background 0.15s var(--pa-ease);
|
||
}
|
||
.source-row:hover { background: var(--pa-surface-2); }
|
||
.source-row.hl-active {
|
||
background: color-mix(in srgb, var(--pa-g-to) 14%, transparent);
|
||
border-radius: var(--pa-r-sm);
|
||
animation: hl-fade 2s var(--pa-ease-out) forwards;
|
||
}
|
||
@keyframes hl-fade {
|
||
0% { background: color-mix(in srgb, var(--pa-g-to) 26%, transparent); }
|
||
100% { background: color-mix(in srgb, var(--pa-g-to) 6%, transparent); }
|
||
}
|
||
.ft-item { display: flex; align-items: flex-start; gap: 8px; }
|
||
.ft-ico { flex: none; width: 16px; text-align: center; }
|
||
.idx { color: var(--pa-ink-subtle); font-size: 11px; line-height: 1.7; }
|
||
.src-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||
.src-line1 { display: flex; align-items: baseline; min-width: 0; }
|
||
.ft-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: var(--pa-ink);
|
||
font-size: 12.5px;
|
||
}
|
||
.src-meta { font-size: 11px; color: var(--pa-ink-subtle); }
|
||
.ft-size { font-size: 11px; color: var(--pa-ink-subtle); }
|
||
.src-snippet {
|
||
font-size: 11.5px;
|
||
line-height: 1.6;
|
||
color: var(--pa-ink-muted);
|
||
font-family: var(--pa-font-ui);
|
||
cursor: pointer;
|
||
word-break: break-word;
|
||
}
|
||
.src-snippet.clamp {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.ft-dl {
|
||
flex: none;
|
||
color: var(--pa-ink-subtle);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding-top: 2px;
|
||
transition: opacity 0.15s var(--pa-ease), color 0.15s var(--pa-ease);
|
||
}
|
||
.ft-dl svg { width: 13px; height: 13px; }
|
||
.ft-dl:hover { color: var(--pa-g-to); }
|
||
/* 移动端触摸:链接图标常驻(FR-RESP-007 由 media (hover:none) 兜底,此处对齐) */
|
||
@media (hover: none) {
|
||
.ft-dl { opacity: 1; }
|
||
}
|
||
</style>
|