Files
backend/frontend/src/components/literature/LiteratureCard.vue
T

285 lines
13 KiB
Vue
Raw Normal View History

<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'
2026-07-27 08:35:12 +08:00
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
2026-07-27 08:35:12 +08:00
displaySettings?: DisplaySettings
}>()
const emit = defineEmits(['detail', 'preview', 'save', 'dismiss'])
const message = useMessage()
2026-07-27 08:35:12 +08:00
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
})
// ── 搜索关键词高亮 ──
2026-07-27 08:35:12 +08:00
// 从查询中提取纯文本词(去掉 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: 去掉通配符
2026-07-27 08:35:12 +08:00
.replace(/"/g, '')
.replace(/\s+/g, ' ')
.trim()
}
const highlightedTitle = computed(() => {
const title = props.item.title || ''
2026-07-27 08:35:12 +08:00
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('|')
2026-07-27 08:35:12 +08:00
const parts = title.split(new RegExp(`(${pattern})`, 'gi'))
return parts.map((part: string) =>
2026-07-27 08:35:12 +08:00
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)">
2026-07-27 08:35:12 +08:00
<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>
2026-07-27 08:35:12 +08:00
</template>
<span v-if="highlightedTitle" v-html="highlightedTitle"></span>
<span v-else>{{ item.title || '' }}</span>
</div>
<!-- ═══ 元信息行 ═══ -->
<div class="lit-meta">
2026-07-27 08:35:12 +08:00
<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 / 引用 ═══ -->
2026-07-27 08:35:12 +08:00
<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>
2026-07-27 08:35:12 +08:00
<template v-if="ds.showStudyTypes && item.is_oa">
<NTag type="success" size="tiny" :bordered="false">🔓 免费全文</NTag>
<span class="meta-sep-lit">|</span>
</template>
2026-07-27 08:35:12 +08:00
<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>
2026-07-27 08:35:12 +08:00
<template v-if="ds.showDoi && item.doi && ds.showPmid && item.pmid">
<span class="meta-sep-lit">|</span>
</template>
2026-07-27 08:35:12 +08:00
<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>
2026-07-27 08:35:12 +08:00
<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>
<!-- ═══ 标签 ═══ -->
2026-07-27 08:35:12 +08:00
<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 摘要 ═══ -->
2026-07-27 08:35:12 +08:00
<div v-if="ds.showAiSummary && item.ai_summary && !compact" class="lit-ai">{{ item.ai_summary }}</div>
<!-- ═══ 操作按钮 ═══ -->
2026-07-27 08:35:12 +08:00
<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>