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参数清理
285 lines
13 KiB
Vue
285 lines
13 KiB
Vue
<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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
}
|
||
</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>
|