feat: initial commit - oncology literature search platform
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

OncoLit: a multi-tenant oncology literature search, feed, and
collaboration platform. Built with FastAPI + Vue 3 + PostgreSQL.
Includes PubMed pipeline, drug approvals, AI summaries, and
systematic review tools.
This commit is contained in:
34047007@qq.com
2026-07-27 07:59:18 +08:00
commit a6cd99a4ca
473 changed files with 151472 additions and 0 deletions
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { NAlert } from 'naive-ui'
const offline = ref(!navigator.onLine)
function updateStatus() { offline.value = !navigator.onLine }
onMounted(() => {
window.addEventListener('online', updateStatus)
window.addEventListener('offline', updateStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateStatus)
window.removeEventListener('offline', updateStatus)
})
</script>
<template>
<NAlert v-if="offline" type="error" :bordered="false" style="border-radius:0;text-align:center"
title="网络连接已断开。部分功能可能不可用。" closable />
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { NBadge, NButton, NPopover, NList, NListItem, NEmpty } from 'naive-ui'
import { RouterLink } from 'vue-router'
import { api } from '../../api/client'
import type { NotificationItem } from '../../types'
import { useToast } from '../../composables/useToast'
import { toBeijingDateTime } from '../../utils/date'
const toast = useToast()
const unread = ref(0)
const items = ref<NotificationItem[]>([])
const showPopover = ref(false)
let intervalId: ReturnType<typeof setInterval> | null = null
onMounted(() => {
loadNotifications()
intervalId = setInterval(loadNotifications, 60000)
})
onUnmounted(() => { if (intervalId) clearInterval(intervalId) })
async function loadNotifications() {
try {
const { data } = await api.get('/notifications')
items.value = (data.items || []).slice(0, 10)
unread.value = data.unread || items.value.filter((i: NotificationItem) => !i.is_read).length
} catch (e) { toast.apiError(e, "加载通知失败") }
}
async function markRead(nid: string) {
try { await api.post(`/notifications/${nid}/read`); unread.value = Math.max(0, unread.value - 1) } catch (e) { toast.apiError(e, "标记已读失败") }
}
function handlePopoverShow(show: boolean) { if (show) loadNotifications() }
</script>
<template>
<NPopover v-model:show="showPopover" trigger="click" placement="bottom-end" @update:show="handlePopoverShow" style="max-width:360px">
<template #trigger>
<NButton text size="small" style="color:inherit" title="通知">
<NBadge :value="unread" :max="99" :show="unread > 0" :offset="[-2, 4]">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>
</NBadge>
</NButton>
</template>
<div style="width:320px">
<NList size="small" v-if="items.length">
<NListItem v-for="n in items.slice(0,8)" :key="n.id"
:style="n.is_read ? {} : { background: 'var(--kw-pill-bg)' }"
@click="markRead(n.id)">
<div style="font-size:13px;font-weight:500">{{ n.title }}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">{{ n.content?.slice(0,40) || '' }}</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">{{ toBeijingDateTime(n.created_at) }}</div>
</NListItem>
</NList>
<NEmpty v-else description="暂无通知" style="padding:16px" />
<div style="text-align:center;border-top:1px solid var(--border-color);padding:6px 0">
<router-link to="/app/notifications" @click="showPopover = false" style="font-size:12px;color:var(--kw-pill-color);text-decoration:none">查看全部 </router-link>
</div>
</div>
</NPopover>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { NSkeleton, NResult, NButton, NIcon } from 'naive-ui'
import { RefreshOutline } from '@vicons/ionicons5'
defineProps<{
loading: boolean
error?: string | null
}>()
const emit = defineEmits<{
retry: []
}>()
</script>
<template>
<!-- 首次加载无结果 骨架 -->
<div v-if="loading" class="skeleton-list">
<div v-for="i in 5" :key="i" class="skeleton-card">
<NSkeleton class="s-title" :sharp="false" />
<NSkeleton class="s-meta" :sharp="false" />
<NSkeleton class="s-meta-short" :sharp="false" />
</div>
</div>
<NResult
v-else-if="error"
status="error"
:title="error"
size="small"
>
<template #footer>
<NButton class="sky-btn" @click="emit('retry')"><template #icon><NIcon size="14"><RefreshOutline /></NIcon></template>重试</NButton>
</template>
</NResult>
<slot v-else />
</template>
<style scoped>
.skeleton-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.skeleton-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 14px;
}
.s-title { height: 22px; margin-bottom: 8px; width: 75%; }
.s-meta { height: 14px; margin-bottom: 6px; width: 60%; }
.s-meta-short { height: 14px; width: 35%; }
</style>
@@ -0,0 +1,259 @@
<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 } 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
}>()
const emit = defineEmits(['detail', 'preview', 'save', 'dismiss'])
const message = useMessage()
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
})
// ── 搜索关键词高亮 ──
const highlightedTitle = computed(() => {
const title = props.item.title || ''
const q = props.searchQuery?.trim()
if (!q) return ''
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const parts = title.split(new RegExp(`(${escaped})`, 'gi'))
return parts.map((part: string) =>
part.toLowerCase() === escaped.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)">
<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>
<span v-if="highlightedTitle" v-html="highlightedTitle"></span>
<span v-else>{{ item.title || '' }}</span>
</div>
<!-- 元信息行 -->
<div class="lit-meta">
<span class="lit-authors">{{ item.first_author || '' }} et al.</span>
<span v-if="item.affiliation" class="affil">🏛 {{ item.affiliation }}</span>
<span v-if="item.journal" class="meta-sep">|</span>
<strong class="lit-journal">{{ item.journal }}</strong>
<NTag v-if="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="studyTypes.length || item.doi || item.pmid || item.is_oa">
<template v-if="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="item.is_oa">
<NTag type="success" size="tiny" :bordered="false">🔓 免费全文</NTag>
<span class="meta-sep-lit">|</span>
</template>
<template v-if="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="item.doi && item.pmid">
<span class="meta-sep-lit">|</span>
</template>
<template v-if="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="item.cited_by_count" class="meta-sep-lit">|</span>
<span v-if="item.cited_by_count" class="cited-count">📊 被引 {{ item.cited_by_count }}</span>
<span v-if="nctId" class="meta-sep-lit">|</span>
<a v-if="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="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="item.ai_summary && !compact" class="lit-ai">{{ item.ai_summary }}</div>
<!-- 操作按钮 -->
<div class="lit-actions" v-if="!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>
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { NDrawer, NDrawerContent, NTag, NButton, NSkeleton, NEmpty, NIcon } from 'naive-ui'
import { DocumentTextOutline } from '@vicons/ionicons5'
import { useToast } from '../../composables/useToast'
import { api } from '../../api/client'
import { computeStudyTypes } from '../../constants/studyTypes'
import { copyToClipboard } from '../../utils/clipboard'
import type { Author, LiteratureDetail } from '../../types'
const props = defineProps<{
show: boolean
pmid: number | null
}>()
const emit = defineEmits(['close', 'goDetail'])
const toast = useToast()
const windowWidth = ref(window.innerWidth)
const drawerWidth = computed(() => Math.min(560, windowWidth.value - 24))
function onResize() { windowWidth.value = window.innerWidth }
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const lit = ref<LiteratureDetail | null>(null)
const loading = ref(false)
watch(() => props.show, async (v) => {
if (v && props.pmid) {
loading.value = true
try {
const { data } = await api.get('/public/literature/' + props.pmid)
lit.value = data
} catch (e) {
toast.apiError(e, "加载文献详情失败")
lit.value = null
} finally {
loading.value = false
}
} else if (!v) {
lit.value = null
}
})
async function copyPmid() {
if (!lit.value?.pmid) return
const ok = await copyToClipboard(String(lit.value.pmid))
if (ok) toast.success('已复制 PMID')
else toast.info(String(lit.value.pmid))
}
async function copyDoi() {
if (!lit.value?.doi) return
const ok = await copyToClipboard(lit.value.doi)
if (ok) toast.success('已复制 DOI')
else toast.info(lit.value.doi)
}
function formatAuthors(authors: Author[]): string {
if (!authors?.length) return ''
return authors.map(a => a.family || a.given || '').filter(Boolean).join(', ')
}
</script>
<template>
<NDrawer :show="show" :width="drawerWidth" :placement="'right'" @update:show="v => !v && emit('close')">
<NDrawerContent title="文献卡片详情" closable>
<NSkeleton v-if="loading" text :repeat="12" />
<NEmpty v-else-if="!lit" description="无法加载文献详情" />
<template v-else>
<!-- 标题 -->
<h2 class="preview-title">{{ lit.title }}</h2>
<!-- 研究类型徽章 -->
<div v-if="lit.pub_types?.length" class="preview-type-row">
<NTag v-for="st in computeStudyTypes(lit.pub_types)" :key="st.label" size="tiny" :type="st.type" :bordered="false">{{ st.label }}</NTag>
</div>
<!-- 元信息 -->
<div class="preview-meta">
<div v-if="lit.authors?.length" class="meta-row">
<span class="meta-label">作者</span>
<span>{{ formatAuthors(lit.authors) }}</span>
</div>
<div v-if="lit.affiliation" class="meta-row">
<span class="meta-label">机构</span>
<span class="meta-affil">🏛 {{ lit.affiliation }}</span>
</div>
<div v-if="lit.journal" class="meta-row">
<span class="meta-label">期刊</span>
<span><strong>{{ lit.journal }}</strong></span>
</div>
<div v-if="lit.pub_date" class="meta-row">
<span class="meta-label">日期</span>
<span>{{ (lit.article_date || lit.pub_date).slice(0, 10) }}</span>
</div>
<div v-if="lit.pmid" class="meta-row">
<span class="meta-label">PMID</span>
<span>{{ lit.pmid }} <span class="copy-btn" @click.stop="copyPmid" title="复制 PMID">📋</span></span>
</div>
<div v-if="lit.doi" class="meta-row">
<span class="meta-label">DOI</span>
<span>{{ lit.doi }} <span class="copy-btn" @click.stop="copyDoi" title="复制 DOI">📋</span></span>
</div>
<div v-if="lit.pmc_id" class="meta-row">
<span class="meta-label">PMCID</span>
<span>{{ lit.pmc_id }} <NTag v-if="lit.is_oa" type="success" size="tiny" :bordered="false">🔓 免费全文</NTag></span>
</div>
<div v-if="lit.cited_by_count" class="meta-row">
<span class="meta-label">引用</span>
<span>📊 被引 {{ lit.cited_by_count }} </span>
</div>
</div>
<!-- 标签 -->
<div v-if="lit.tags?.length" class="preview-tags">
<NTag v-for="tag in lit.tags" :key="tag.id" size="small" :bordered="false">{{ tag.name_zh || tag.path }}</NTag>
</div>
<!-- 关键词 -->
<div v-if="lit.keywords?.length" class="preview-section">
<div class="preview-section-title">关键词</div>
<div class="preview-keywords">
<span v-for="kw in lit.keywords" :key="kw" class="kw-pill">{{ kw }}</span>
</div>
</div>
<!-- GrantList -->
<div v-if="lit.grants?.length" class="preview-section">
<div class="preview-section-title">基金资助</div>
<div v-for="(g, i) in lit.grants" :key="i" class="grant-row">
<span v-if="g.agency">{{ g.agency }}</span>
<span v-if="g.grant_id"> ({{ g.grant_id }})</span>
</div>
</div>
<!-- 摘要 -->
<div class="preview-section">
<div class="preview-section-title">摘要</div>
<p class="preview-abstract">{{ lit.abstract || '摘要不可用' }}</p>
</div>
<!-- AI 摘要 -->
<div v-if="lit.ai_summary" class="preview-section">
<div class="preview-section-title">AI 总结</div>
<p class="preview-ai">{{ lit.ai_summary }}</p>
</div>
<!-- 操作 -->
<div class="preview-actions">
<NButton type="primary" @click="emit('goDetail', lit)" class="sky-btn"><template #icon><NIcon size="14"><DocumentTextOutline /></NIcon></template>阅读原文</NButton>
</div>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.preview-title { font-size: 18px; font-weight: 700; line-height: 1.4; margin-bottom: 12px; }
.preview-type-row { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 12px; }
.preview-meta { margin-bottom: 16px; }
.meta-row { display: flex; gap: 8px; font-size: 13px; margin-bottom: 5px; }
.meta-label { color: var(--text-muted); flex-shrink: 0; min-width: 44px; }
.meta-affil { color: var(--kw-pill-color); }
html.dark .meta-affil { color: #6ab0e0; }
.copy-btn { cursor: pointer; font-size: 12px; }
.preview-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 16px; }
.preview-section { margin-bottom: 18px; }
.preview-section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.preview-abstract { font-size: 13px; color: var(--text-primary); line-height: 1.7; white-space: pre-wrap; }
.preview-ai { font-size: 13px; background: var(--preview-ai-bg); padding: 10px; border-radius: 6px; }
.preview-keywords { display: flex; flex-wrap: wrap; gap: 4px; }
.kw-pill { font-size: 12px; background: var(--kw-pill-bg); color: var(--kw-pill-color); padding: 2px 8px; border-radius: 10px; }
.grant-row { font-size: 12px; color: var(--text-primary); margin-bottom: 2px; }
.preview-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
</style>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { NCard, NTag } from 'naive-ui'
interface TableData {
caption?: string
is_table1?: boolean
headers?: string[]
rows?: string[][]
footnotes?: string[]
}
defineProps<{
tables?: TableData[]
}>()
</script>
<template>
<div v-if="tables?.length" class="table1-viewer">
<div v-for="(tbl, idx) in tables" :key="idx" class="table-wrap-card">
<NCard v-if="tbl.caption || tbl.is_table1" size="small" :title="tbl.caption || `Table ${idx + 1}`">
<template v-if="tbl.is_table1" #header-extra>
<NTag size="tiny" type="success" :bordered="false">Table 1 基线特征</NTag>
</template>
<div class="table-scroll">
<table class="structured-table">
<thead v-if="tbl.headers?.length">
<tr>
<th v-for="(h, hi) in tbl.headers" :key="hi">{{ h }}</th>
</tr>
</thead>
<tbody v-if="tbl.rows?.length">
<tr v-for="(row, ri) in tbl.rows" :key="ri" :class="{ 'table-row-alt': ri % 2 === 1 }">
<td v-for="(cell, ci) in row" :key="ci" :class="{ 'row-label': ci === 0 }">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="tbl.footnotes?.length" class="table-footnotes">
<div v-for="(fn, fi) in tbl.footnotes" :key="fi" class="footnote">[{{ fi + 1 }}] {{ fn }}</div>
</div>
</NCard>
</div>
</div>
<div v-else-if="tables !== undefined" class="table1-empty">
暂无表格数据
</div>
</template>
<style scoped>
.table1-viewer {
display: flex;
flex-direction: column;
gap: 16px;
}
.table-wrap-card {
overflow: hidden;
}
.table-scroll {
overflow-x: auto;
margin: 0 -12px;
padding: 0 12px;
}
.structured-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
line-height: 1.6;
}
.structured-table th {
background: var(--bg-page);
font-weight: 600;
padding: 8px 12px;
text-align: left;
border-bottom: 2px solid #d0d7de;
white-space: nowrap;
}
.structured-table td {
padding: 6px 12px;
border-bottom: 1px solid #e8edf2;
vertical-align: top;
}
.structured-table .row-label {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.table-row-alt td {
background: var(--preview-ai-bg);
}
.table-footnotes {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.footnote {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.table1-empty {
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
</style>
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import LiteratureCard from '../LiteratureCard.vue'
import type { LiteratureItem } from '../../../types'
function vm(wrapper: ReturnType<typeof shallowMount>) {
return wrapper.vm as unknown as { isNew: boolean; isUpdated: boolean }
}
function item(overrides: Partial<LiteratureItem> = {}): LiteratureItem {
return {
pmid: 12345,
title: 'Cancer Immunotherapy Trial',
first_author: 'Smith J',
journal: 'New England Journal of Medicine',
pub_date: '2024-06-01',
doi: '10.1056/NEJMoa2400001',
...overrides,
}
}
describe('LiteratureCard', () => {
it('renders title, author, journal', () => {
const wrapper = shallowMount(LiteratureCard, { props: { item: item() } })
expect(wrapper.text()).toContain('Cancer Immunotherapy Trial')
expect(wrapper.text()).toContain('Smith J')
expect(wrapper.text()).toContain('New England Journal of Medicine')
})
it('isNew is true for recently created items', () => {
const wrapper = shallowMount(LiteratureCard, {
props: { item: item({ created_at: new Date().toISOString() }) },
})
expect(vm(wrapper).isNew).toBe(true)
})
it('isNew is false without created_at', () => {
const wrapper = shallowMount(LiteratureCard, { props: { item: item() } })
expect(vm(wrapper).isNew).toBe(false)
})
it('shows saved state via CSS class', () => {
const wrapper = shallowMount(LiteratureCard, {
props: { item: item({ pmid: 999 }), savedPmids: new Set([999]) },
})
expect(wrapper.find('.save-btn.saved').exists()).toBe(true)
})
it('shows AI summary in non-compact mode', () => {
const wrapper = shallowMount(LiteratureCard, {
props: { item: item({ ai_summary: 'Key finding summary.' }) },
})
expect(wrapper.text()).toContain('Key finding summary.')
})
it('hides AI summary in compact mode', () => {
const wrapper = shallowMount(LiteratureCard, {
props: { item: item({ ai_summary: 'Key finding summary.' }), compact: true },
})
expect(wrapper.text()).not.toContain('Key finding summary.')
})
it('hides action buttons in compact mode', () => {
const wrapper = shallowMount(LiteratureCard, {
props: { item: item(), compact: true },
})
expect(wrapper.find('.lit-actions').exists()).toBe(false)
})
it('emits detail on title click', async () => {
const testItem = item()
const wrapper = shallowMount(LiteratureCard, { props: { item: testItem } })
await wrapper.find('.lit-title-link').trigger('click')
expect(wrapper.emitted('detail')).toBeTruthy()
expect(wrapper.emitted('detail')![0]![0]).toStrictEqual(testItem)
})
it('emits preview on expand click', async () => {
const testItem = item()
const wrapper = shallowMount(LiteratureCard, { props: { item: testItem } })
await wrapper.find('.expand-btn').trigger('click')
expect(wrapper.emitted('preview')).toBeTruthy()
expect(wrapper.emitted('preview')![0]![0]).toStrictEqual(testItem)
})
it('emits save on save click', async () => {
const testItem = item()
const wrapper = shallowMount(LiteratureCard, { props: { item: testItem } })
await wrapper.find('.save-btn').trigger('click')
expect(wrapper.emitted('save')).toBeTruthy()
expect(wrapper.emitted('save')![0]![0]).toStrictEqual(testItem)
})
it('emits dismiss on dismiss click', async () => {
const testItem = item()
const wrapper = shallowMount(LiteratureCard, { props: { item: testItem } })
const btns = wrapper.findAll('.action-btn')
await btns[btns.length - 1]!.trigger('click')
expect(wrapper.emitted('dismiss')).toBeTruthy()
expect(wrapper.emitted('dismiss')![0]![0]).toStrictEqual(testItem)
})
it('renders cited by count', () => {
const wrapper = shallowMount(LiteratureCard, {
props: { item: item({ cited_by_count: 42 }) },
})
expect(wrapper.text()).toContain('42')
})
})
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import LiteraturePreviewDrawer from '../LiteraturePreviewDrawer.vue'
const mockLitData = {
pmid: 12345,
title: 'Phase III Trial of Pembrolizumab',
authors: [{ family: 'Smith', given: 'J' }],
journal: 'NEJM',
pub_date: '2024-06-01',
doi: '10.1056/NEJMoa2400001',
abstract: 'This study evaluated pembrolizumab efficacy.',
}
interface DrawerVM {
lit: unknown
loading: boolean
copyPmid: () => void
copyDoi: () => void
}
function vm(wrapper: ReturnType<typeof shallowMount>): DrawerVM {
return wrapper.vm as unknown as DrawerVM
}
vi.mock('../../../api/client', () => ({
api: { get: vi.fn() },
}))
describe('LiteraturePreviewDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not fetch when mounted with show=false', async () => {
const { api } = await import('../../../api/client')
shallowMount(LiteraturePreviewDrawer, {
props: { show: false, pmid: 12345 },
})
expect(api.get).not.toHaveBeenCalled()
})
it('fetches data when show becomes true and stores it internally', async () => {
const { api } = await import('../../../api/client')
vi.mocked(api.get).mockResolvedValue({ data: mockLitData })
const wrapper = shallowMount(LiteraturePreviewDrawer, {
props: { show: false, pmid: 12345 },
})
expect(vm(wrapper).lit).toBeNull()
await wrapper.setProps({ show: true })
await new Promise(r => setTimeout(r, 0))
expect(api.get).toHaveBeenCalledWith('/public/literature/12345')
expect(vm(wrapper).lit).toEqual(mockLitData)
expect(vm(wrapper).loading).toBe(false)
})
it('handles API error gracefully', async () => {
const { api } = await import('../../../api/client')
vi.mocked(api.get).mockRejectedValue(new Error('Network error'))
const wrapper = shallowMount(LiteraturePreviewDrawer, {
props: { show: true, pmid: 12345 },
})
await new Promise(r => setTimeout(r, 0))
expect(vm(wrapper).lit).toBeNull()
expect(vm(wrapper).loading).toBe(false)
})
it('resets lit when show becomes false', async () => {
const { api } = await import('../../../api/client')
vi.mocked(api.get).mockResolvedValue({ data: mockLitData })
const wrapper = shallowMount(LiteraturePreviewDrawer, {
props: { show: false, pmid: 12345 },
})
await wrapper.setProps({ show: true })
await new Promise(r => setTimeout(r, 0))
expect(vm(wrapper).lit).toEqual(mockLitData)
await wrapper.setProps({ show: false })
expect(vm(wrapper).lit).toBeNull()
})
it('watcher resets lit on show=false', async () => {
const wrapper = shallowMount(LiteraturePreviewDrawer, {
props: { show: true, pmid: 12345 },
})
await wrapper.setProps({ show: false })
expect(vm(wrapper).lit).toBeNull()
})
it('exposes copyPmid and copyDoi functions', () => {
const wrapper = shallowMount(LiteraturePreviewDrawer, {
props: { show: true, pmid: 12345 },
})
vm(wrapper).lit = mockLitData
expect(typeof vm(wrapper).copyPmid).toBe('function')
expect(typeof vm(wrapper).copyDoi).toBe('function')
})
})
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { NModal, NForm, NFormItem, NInput, NSwitch, NSpace, NButton, NIcon } from 'naive-ui'
import { CloseOutline, SaveOutline } from '@vicons/ionicons5'
defineProps<{
show: boolean
content: string
isPrivate: boolean
saving: boolean
}>()
const emit = defineEmits<{
'update:show': [v: boolean]
'update:content': [v: string]
'update:isPrivate': [v: boolean]
save: []
}>()
</script>
<template>
<NModal :show="show" title="编辑笔记" preset="card" style="width:620px" @update:show="(v: boolean) => emit('update:show', v)">
<NForm label-placement="top">
<NFormItem label="笔记内容">
<div style="width:100%">
<NInput
:value="content"
type="textarea"
:rows="6"
placeholder="输入笔记内容…"
:maxlength="1000"
@update:value="(v: string) => emit('update:content', v)"
/>
<div v-if="content.length > 500" style="margin-top:4px;font-size:12px;color:#e67e22">
已超过 500 {{ content.length }}超出部分将被截断
</div>
</div>
</NFormItem>
<NFormItem label="公开">
<NSwitch :value="!isPrivate" @update:value="(v: boolean) => emit('update:isPrivate', !v)" />
<span style="margin-left:8px;font-size:13px;color:#999">{{ isPrivate ? '仅自己可见' : '对团队成员公开' }}</span>
</NFormItem>
<NSpace justify="end" style="margin-top:12px">
<NButton class="sky-btn" @click="emit('update:show', false)"><template #icon><NIcon size="14"><CloseOutline /></NIcon></template>取消</NButton>
<NButton type="primary" class="sky-btn" :loading="saving" @click="emit('save')"><template #icon><NIcon size="14"><SaveOutline /></NIcon></template>保存</NButton>
</NSpace>
</NForm>
</NModal>
</template>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NInput, NButton, NDatePicker, NCheckbox, NCheckboxGroup, NSelect, NIcon, NRadio, NRadioGroup } from 'naive-ui'
import { SearchOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useToast } from '../../composables/useToast'
const props = defineProps<{
show?: boolean
loading?: boolean
tags?: any[]
}>()
const emit = defineEmits<{
search: [params: { query: string; field: string; date_from: string | null; date_to: string | null; journal_tiers: string[]; tag_ids: string[]; retracted: string; negative_result: string; precision_mode: string; sort: string }]
}>()
const toast = useToast()
const query = ref('')
const dateFrom = ref<number | null>(null)
const dateTo = ref<number | null>(null)
const selectedTiers = ref<string[]>([])
const selectedTags = ref<string[]>([])
const retracted = ref('')
const negativeResult = ref('')
const precisionMode = ref('majr')
const sort = ref('date')
const field = ref('all')
const fieldOptions = [
{ label: '全部', value: 'all' },
{ label: '标题', value: 'title' },
{ label: '摘要', value: 'abstract' },
{ label: '作者', value: 'author' },
{ label: '机构', value: 'affiliation' },
{ label: '期刊', value: 'journal' },
]
const tagOptions = ref<any[]>([])
const fieldPlaceholder: Record<string, string> = {
all: '标题、摘要、作者、机构、PMID、DOI...',
title: '搜索标题...',
abstract: '搜索摘要...',
author: '搜索作者(英文名)...',
affiliation: '搜索机构(英文名)...',
journal: '搜索期刊名称...',
}
onMounted(async () => {
if (props.tags && props.tags.length) {
tagOptions.value = (props.tags as any[]).filter((t: any) => t.level === 2 && t.is_selectable).slice(0, 12)
} else {
try {
const { data } = await api.get('/public/tags')
tagOptions.value = (data.tags || []).filter((t: any) => t.level === 2 && t.is_selectable).slice(0, 12)
} catch (e) { toast.apiError(e, '加载标签失败') }
}
})
function fmtDate(ts: number | null): string {
if (!ts) return ''
return new Date(ts).toLocaleDateString('sv')
}
function handleSearch() {
emit('search', {
query: query.value,
field: field.value,
date_from: fmtDate(dateFrom.value),
date_to: fmtDate(dateTo.value),
journal_tiers: selectedTiers.value,
tag_ids: selectedTags.value,
retracted: retracted.value,
negative_result: negativeResult.value,
precision_mode: precisionMode.value,
sort: sort.value,
})
}
function resetFilters() {
query.value = ''
field.value = 'all'
dateFrom.value = null
dateTo.value = null
selectedTiers.value = []
selectedTags.value = []
retracted.value = ''
negativeResult.value = ''
precisionMode.value = 'majr'
sort.value = 'date'
}
</script>
<template>
<div v-if="show" class="adv-filter-panel" @mousedown.stop>
<div class="filter-row">
<label class="filter-label">🔍 关键词</label>
<div class="filter-field-row">
<NSelect v-model:value="field" :options="fieldOptions" size="small" style="width:100px;flex-shrink:0" />
<NInput v-model:value="query" :placeholder="fieldPlaceholder[field] || fieldPlaceholder['all']" size="small" clearable @keyup.enter="handleSearch" />
</div>
</div>
<div class="filter-row">
<label class="filter-label">📆 日期范围</label>
<div class="filter-inline">
<NDatePicker v-model:value="dateFrom" type="date" placeholder="起始日期" clearable style="flex:1" />
<span class="filter-sep"></span>
<NDatePicker v-model:value="dateTo" type="date" placeholder="截止日期" clearable style="flex:1" />
</div>
</div>
<div class="filter-row">
<label class="filter-label">📰 期刊等级</label>
<NCheckboxGroup v-model:value="selectedTiers">
<div class="checkbox-row">
<NCheckbox value="1" size="small">四大综合</NCheckbox>
<NCheckbox value="2" size="small">肿瘤顶刊</NCheckbox>
<NCheckbox value="3" size="small">专科顶刊</NCheckbox>
<NCheckbox value="4" size="small">其他SCI</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<div class="filter-row">
<label class="filter-label">🏷 热门标签</label>
<div class="tag-grid">
<NCheckbox v-for="t in tagOptions" :key="t.id" :value="t.id" size="small">{{ t.name_zh }}</NCheckbox>
</div>
</div>
<div class="filter-row">
<div class="filter-inline" style="flex-wrap:wrap;gap:24px">
<div>
<label class="filter-label"> 撤稿</label>
<NRadioGroup v-model:value="retracted" name="retracted">
<NRadio value="" size="small">全部</NRadio>
<NRadio value="only" size="small">仅撤稿</NRadio>
<NRadio value="no" size="small">未撤稿</NRadio>
</NRadioGroup>
</div>
<div>
<label class="filter-label">📊 结果类型</label>
<NRadioGroup v-model:value="negativeResult" name="resultType">
<NRadio value="" size="small">全部</NRadio>
<NRadio value="no" size="small">阳性结果</NRadio>
<NRadio value="only" size="small">阴性结果</NRadio>
</NRadioGroup>
</div>
<div>
<label class="filter-label">🎯 精度模式</label>
<NRadioGroup v-model:value="precisionMode" name="precision">
<NRadio value="" size="small">全部</NRadio>
<NRadio value="majr" size="small">🎯 高精度 (Major Topic)</NRadio>
<NRadio value="mesh" size="small">📚 高召回 (MeSH)</NRadio>
</NRadioGroup>
</div>
<div>
<label class="filter-label">📊 排序</label>
<NSelect v-model:value="sort" :options="[
{label:'日期排序',value:'date'},
{label:'被引次数',value:'cited'},
{label:'相关度',value:'relevance'},
]" size="small" style="width:140px" />
</div>
</div>
</div>
<div class="filter-row" style="margin-bottom:0">
<div class="filter-actions">
<NButton size="small" type="primary" class="sky-btn" :loading="loading" @click="handleSearch"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton>
<NButton size="small" text @click="resetFilters">重置</NButton>
</div>
</div>
</div>
</template>
<style scoped>
.adv-filter-panel {
margin-top: 10px;
padding: 16px;
background: var(--preview-ai-bg);
border-radius: 8px;
border: 1px solid var(--border-color, #eee);
}
.filter-row { margin-bottom: 14px; }
.filter-row:last-child { margin-bottom: 0; }
.filter-label { display: block; font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.filter-inline { display: flex; align-items: center; gap: 8px; }
.filter-field-row { display: flex; align-items: center; gap: 8px; }
.filter-sep { font-size: 12px; color: var(--text-muted); flex-shrink: 0; }
.checkbox-row { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.tag-grid { display: flex; flex-wrap: wrap; gap: 6px 12px; max-height: 200px; overflow-y: auto; }
.filter-actions { display: flex; gap: 8px; }
</style>