Files
backend/frontend/src/components/literature/LiteratureCard.vue
T
34047007@qq.com 20bee0b351 fix: PubMed搜索合规 — 34项修复 + query_expansion UnboundLocalError + HomeView precision_mode残留
Batch1 — 解析器 (pubmed_query_parser.py)
- P0-1: 未知字段标签降级为 WORD token 而非 ParseError
- P2-1: 增加未消费 token 检查
- P2-2: PRISMA 字段标签正则 [\w:+] → [\w:]+
- P2-3: Unicode NFKC 规格化输入
- P2-4: re.ASCII 防止 Unicode 数字匹配
- P2-9: 移除重复 dataclass 字段

Batch2 — 搜索引擎 (search_engine.py)
- P1-1: 批量 PMID 查询替代 N+1 循环
- P1-2: isdigit() → isdecimal()
- P1-5: 移除 precision_mode 参数
- P2-5: 移除死代码
- P2-6: 统一 _CHINESE_RE 正则

Batch3 — ATM 引擎 (query_expansion.py)
- P0-4: name_zh ILIKE 中文回退 + _find_mesh_tags 中文降级
- P1-3: name_en ILIKE 加 LIMIT 100

Batch4 — API 层 (features.py)
- P1-5: 移除 precision_mode 请求字段
- P1-11: 增加 logging
- P2-8: NLM_SUBSET_LABELS f-string 安全注释
- P2-12: split 校验器近似性注释

Batch5 — SearchView.vue
- P0-5a/b/c: 日期修复(UTC 方法、互斥逻辑、restoreFromQuery 合并)
- P2-10: 筛选模态关闭时重搜
- P3-1: 搜索框 aria-label

Batch6 — HomeView.vue
- P1-10: URL date → date_from/date_to
- P2-11: clearSearch 清空 feedItems 并重加载

Batch7 — LiteratureCard.vue
- P1-6: 字段标签正则 [\w-]+ → [\w:-]+
- P1-7: terms 切片限制 20 项防 ReDoS

后修复:
- query_expansion.py _find_partial_mesh_tags UnboundLocalError(单非中文词未初始化 tag_ids)
- HomeView.vue handleAdvancedSearch precision_mode 残留引用
2026-07-27 09:45:17 +08:00

286 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] including [MH:noexp]
.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 ''
// 拆分为独立词项,逐词高亮(多词查询不拼成一个连写短语)
// P1-7: 限制最多 20 个高亮词,防止正则 ReDoS
const terms = plain.split(/\s+/).filter(t => t.length > 0).slice(0, 20)
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>