Files
backend/frontend/src/components/literature/LiteratureCard.vue
T
34047007@qq.com 5f69277eba
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
feat: PubMed搜索合规审计 Phase 5-7 全部修复
P1-2: [SB][STAT][UID][DEP][MESH]字段支持
P1-4: [MH:noexp]解析+抑制树展开
P3: 前端date_to/P3-1、CJK高亮/P3-3、extractPlainText/P3-4、precision_mode移除/P3-6、loadMore sort/P3-5
P4-1: 词数上限50→100
P4-2: retracted参数清理
2026-07-27 09:03:19 +08:00

285 lines
13 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 lang="ts">
import { computed } from 'vue'
import { NTag } from 'naive-ui'
import { computeStudyTypes } from '../../constants/studyTypes'
import type { StudyDesign, TrialReg, RctDetection } from '../../types'
import type { LiteratureItem, DisplaySettings } from '../../types'
import { useMessage } from 'naive-ui'
import { copyToClipboard } from '../../utils/clipboard'
const props = defineProps<{
item: LiteratureItem
compact?: boolean
showSave?: boolean
savedPmids?: Set<number>
searchQuery?: string
displaySettings?: DisplaySettings
}>()
const emit = defineEmits(['detail', 'preview', 'save', 'dismiss'])
const message = useMessage()
const ds = computed(() => props.displaySettings || {} as DisplaySettings)
function onSave(e: Event) { e.stopPropagation(); emit('save', props.item) }
function onDismiss(e: Event) { e.stopPropagation(); emit('dismiss', props.item) }
async function copyPmid(e: Event) {
e.stopPropagation()
const pmid = props.item?.pmid
if (!pmid) return
const ok = await copyToClipboard(String(pmid))
if (ok) message.success('已复制 PMID')
else message.info('PMID: ' + pmid)
}
async function copyDoi(e: Event) {
e.stopPropagation()
const doi = props.item?.doi
if (!doi) return
const ok = await copyToClipboard(String(doi))
if (ok) message.success('已复制 DOI')
else message.info('DOI: ' + doi)
}
// ── 新收录 / 已更新 ──
const isNew = computed(() => {
if (!props.item.created_at) return false
const created = new Date(props.item.created_at).getTime()
const diffDays = (Date.now() - created) / (1000 * 60 * 60 * 24)
return diffDays <= 7
})
const isUpdated = computed(() => {
if (isNew.value) return false
if (!props.item.created_at || !props.item.updated_at) return false
return new Date(props.item.updated_at).getTime() > new Date(props.item.created_at).getTime()
})
// ── 已读/未读 ──
const isRead = computed(() => {
return !!(props.item as any).is_read
})
// ── 收藏状态 ──
const isSaved = computed(() => {
if (!props.savedPmids || !props.item.pmid) return false
return props.savedPmids.has(props.item.pmid)
})
// ── 期刊分区标签类型 ──
const tierType = computed<'success' | 'info' | 'warning' | 'default' | null>(() => {
const t = props.item.journal_tier
if (!t) return null
const m: Record<string, 'success' | 'info' | 'warning' | 'default'> = { '1': 'success', '2': 'info', '3': 'warning', '4': 'default' }
return m[String(t)] || null
})
const studyTypes = computed(() => computeStudyTypes(props.item.pub_types || []))
// 显示日期:优先 article_date,回退 pub_date,未来日期截断
const displayDate = computed(() => {
const raw = props.item.article_date || props.item.pub_date
if (!raw) return ''
const dateStr = raw.slice(0, 10)
const parsed = new Date(dateStr)
if (!isNaN(parsed.getTime()) && parsed > new Date()) {
return new Date().toISOString().slice(0, 10)
}
return dateStr
})
// 研究设计分类标签
const designLabel = computed(() => {
const sd = props.item.study_design as StudyDesign | undefined
return sd?.label_zh || null
})
// RCT 检测标签
const rctInfo = computed(() => {
const rct = props.item.rct_detection as RctDetection | undefined
if (!rct?.is_rct) return null
return rct
})
// 临床试验注册链接
const nctId = computed(() => {
const tr = props.item.trial_reg as TrialReg | undefined
return tr?.nct || null
})
// ── 搜索关键词高亮 ──
// 从查询中提取纯文本词(去掉 PubMed 字段标签如 [TI]、[AB] 等)
function extractPlainText(q: string): string {
return q
.replace(/\[[\w-]+\]/g, '') // [field tags]
.replace(/"?\b(AND|OR|NOT)\b"?/gi, '')// boolean operators
.replace(/[()]/g, '') // P3-4: 去掉括号
.replace(/#\d+/g, '') // P3-4: 去掉 #N 引用标记
.replace(/\*/g, '') // P3-4: 去掉通配符
.replace(/"/g, '')
.replace(/\s+/g, ' ')
.trim()
}
const highlightedTitle = computed(() => {
const title = props.item.title || ''
const raw = props.searchQuery?.trim()
if (!raw) return ''
const plain = extractPlainText(raw)
if (!plain) return ''
// 拆分为独立词项,逐词高亮(多词查询不拼成一个连写短语)
const terms = plain.split(/\s+/).filter(t => t.length > 0)
if (terms.length === 0) return ''
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
// P3-3: 单字符词添加 \b 词边界,但 CJK 字符跳过(\b 对 CJK 无效)
const isCJK = (c: string) => /[-鿿㐀-䶿豈-﫿]/.test(c)
const pattern = escaped.map(e => e.length === 1 && !isCJK(e) ? `\\b${e}\\b` : e).join('|')
const parts = title.split(new RegExp(`(${pattern})`, 'gi'))
return parts.map((part: string) =>
terms.some(t => part.toLowerCase() === t.toLowerCase())
? `<mark style="background:#fff3b0;padding:0 2px;border-radius:2px">${escapeHtml(part)}</mark>`
: escapeHtml(part)
).join('')
})
function escapeHtml(s: string) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
}
</script>
<template>
<div class="lit-card" :class="{ compact, 'is-read': isRead }">
<!-- ═══ 标题徽章内联点击跳转详情 ═══ -->
<div class="lit-title lit-title-link" @click.stop="emit('detail', item)">
<template v-if="ds.showBadges">
<NTag v-if="isNew" size="tiny" type="info" :bordered="false" class="badge-inline">🆕</NTag>
<NTag v-if="isUpdated" size="tiny" type="success" :bordered="false" class="badge-inline">🔄</NTag>
<NTag v-if="item.retracted" size="tiny" type="error" :bordered="false" class="badge-inline">已撤稿</NTag>
<NTag v-if="item.is_negative_result && !item.retracted" size="tiny" type="warning" :bordered="false" class="badge-inline">阴性结果</NTag>
<NTag v-if="rctInfo?.confidence === 'confirmed'" size="tiny" type="error" :bordered="false" class="badge-inline">RCT </NTag>
<NTag v-else-if="rctInfo?.confidence === 'suspected'" size="tiny" type="info" :bordered="false" class="badge-inline">RCT ?</NTag>
</template>
<span v-if="highlightedTitle" v-html="highlightedTitle"></span>
<span v-else>{{ item.title || '' }}</span>
</div>
<!-- ═══ 元信息行 ═══ -->
<div class="lit-meta">
<span v-if="ds.showAuthors" class="lit-authors">{{ item.first_author || '' }} et al.</span>
<span v-if="ds.showAffiliation && item.affiliation" class="affil">🏛 {{ item.affiliation }}</span>
<span v-if="ds.showJournal && item.journal" class="meta-sep">|</span>
<strong v-if="ds.showJournal && item.journal" class="lit-journal">{{ item.journal }}</strong>
<NTag v-if="ds.showJournal && tierType" :type="tierType" size="tiny" :bordered="false" class="tier-tag">Q{{ item.journal_tier }}</NTag>
<span class="meta-sep">|</span>
<span v-if="displayDate" class="lit-date">{{ displayDate }}</span>
</div>
<!-- ═══ DOI / PMID / 研究类型 / OA / 引用 ═══ -->
<div class="lit-doi" v-if="(ds.showStudyTypes && (studyTypes.length || designLabel)) || (item.doi && ds.showDoi) || (item.pmid && ds.showPmid) || (item.is_oa && ds.showStudyTypes) || (item.cited_by_count && ds.showCitedBy) || (nctId && ds.showStudyTypes)">
<template v-if="ds.showStudyTypes && studyTypes.length">
<NTag v-for="st in studyTypes" :type="st.type" size="tiny" :bordered="false">{{ st.label }}</NTag>
<NTag v-if="designLabel" type="info" size="tiny" :bordered="false">{{ designLabel }}</NTag>
<span class="meta-sep-lit">|</span>
</template>
<template v-if="ds.showStudyTypes && item.is_oa">
<NTag type="success" size="tiny" :bordered="false">🔓 免费全文</NTag>
<span class="meta-sep-lit">|</span>
</template>
<template v-if="ds.showDoi && item.doi">
<span class="doi-label">DOI:</span>
<a :href="'https://doi.org/' + item.doi" target="_blank" rel="noopener" class="doi-link" @click.stop>{{ item.doi }}</a>
<span class="doi-copy-btn" @click.stop="copyDoi" title="复制 DOI">📋</span>
</template>
<template v-if="ds.showDoi && item.doi && ds.showPmid && item.pmid">
<span class="meta-sep-lit">|</span>
</template>
<template v-if="ds.showPmid && item.pmid">
<span class="doi-label">PMID:</span>
<span class="pmid-text">{{ item.pmid }}</span>
<span class="doi-copy-btn" @click.stop="copyPmid" title="复制 PMID">📋</span>
</template>
<span v-if="ds.showCitedBy && item.cited_by_count" class="meta-sep-lit">|</span>
<span v-if="ds.showCitedBy && item.cited_by_count" class="cited-count">📊 被引 {{ item.cited_by_count }}</span>
<span v-if="ds.showStudyTypes && nctId" class="meta-sep-lit">|</span>
<a v-if="ds.showStudyTypes && nctId" :href="'https://clinicaltrials.gov/study/' + nctId" target="_blank" rel="noopener" class="nct-link" @click.stop>{{ nctId }}</a>
</div>
<!-- ═══ 标签 ═══ -->
<div class="lit-tags" v-if="ds.showTags && item.tags?.length">
<NTag v-for="t in item.tags.slice(0,6)" :key="t.id||t.name_zh" size="tiny" :bordered="false">{{ t.name_zh || t.name_en }}</NTag>
</div>
<!-- ═══ AI 摘要 ═══ -->
<div v-if="ds.showAiSummary && item.ai_summary && !compact" class="lit-ai">{{ item.ai_summary }}</div>
<!-- ═══ 操作按钮 ═══ -->
<div class="lit-actions" v-if="ds.showActions && !compact">
<button class="action-btn expand-btn" @click.stop="emit('preview', item)" title="快速预览"></button>
<button class="action-btn save-btn" :class="{ saved: isSaved }" @click="onSave">
{{ isSaved ? '⭐' : '☆' }} 收藏
</button>
<button class="action-btn" @click="onDismiss"> 不感兴趣</button>
</div>
</div>
</template>
<style scoped>
.lit-card { background: var(--bg-card); border-radius: 8px; padding: 8px 10px; margin-bottom: 6px; transition: box-shadow .15s; border: 1px solid var(--border-color); }
.lit-card.is-read { opacity: 0.55; border-left: 3px solid var(--border-color); }
.lit-card:hover { box-shadow: 0 2px 12px var(--shadow); }
.lit-card.compact { padding: 5px 6px; }
/* 标题(可点击跳转) */
.lit-title { font-size: 16px; font-weight: 600; line-height: 1.4; margin-bottom: 4px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; color: var(--text-primary); }
.lit-title-link { cursor: pointer; }
.lit-title-link:hover { color: var(--kw-pill-color); }
html.dark .lit-title-link:hover { color: #6ab0e0; }
.badge-inline { margin-right: 4px; }
/* 元信息 */
.lit-meta { font-size: 14px; color: var(--text-muted); margin-bottom: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; }
.meta-sep { color: var(--border-color); margin: 0 3px; }
.lit-journal { color: var(--text-secondary); }
.tier-tag { margin: 0 0 0 2px; }
.lit-date { cursor: default; white-space: nowrap; }
.affil { color: var(--kw-pill-color); margin-right: 2px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: bottom; }
html.dark .affil { color: #6ab0e0; }
/* DOI */
.lit-doi { font-size: 14px; margin-bottom: 4px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.doi-label { color: var(--text-muted); white-space: nowrap; }
.doi-link { color: var(--kw-pill-color); text-decoration: none; border-bottom: 1px dashed var(--border-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 340px; display: inline-block; vertical-align: bottom; }
.doi-link:hover { border-bottom-color: var(--kw-pill-color); }
html.dark .doi-link { color: #6ab0e0; }
html.dark .doi-link:hover { border-bottom-color: #6ab0e0; }
.doi-copy-btn { cursor: pointer; font-size: 12px; flex-shrink: 0; }
.doi-copy-btn:hover { opacity: .7; }
.pmid-text { color: var(--text-secondary); }
.cited-count { color: var(--text-muted); white-space: nowrap; }
.nct-link { color: #c0392b; text-decoration: none; font-size: 11px; font-weight: 600; }
.nct-link:hover { text-decoration: underline; }
.meta-sep-lit { color: var(--border-color); margin: 0 6px; }
/* 标签 */
.lit-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px; }
/* AI 摘要 */
.lit-ai { font-size: 14px; color: var(--text-secondary); line-height: 1.6; padding: 8px 0 0; border-top: 1px solid var(--border-color); margin-top: 4px; }
/* 操作按钮 */
.lit-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.action-btn { font-size: 14px; color: var(--text-muted); cursor: pointer; user-select: none; display: inline-flex; align-items: center; background: none; border: none; padding: 0; }
.action-btn:hover { color: var(--kw-pill-color); }
html.dark .action-btn:hover { color: #6ab0e0; }
.expand-btn { font-size: 14px; padding: 0 2px; }
.save-btn { display: inline-flex; align-items: center; gap: 2px; }
.save-btn.saved { color: #f39c12; }
/* 移动端:防止卡片任何元素撑出 */
@media (max-width: 768px) {
.lit-card { padding: 6px 8px; overflow-x: hidden; }
.lit-title { font-size: 15px; }
.doi-link { max-width: 180px; }
.affil { max-width: 120px; }
}
</style>