feat: initial commit - oncology literature search platform
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:
@@ -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,'&').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)">
|
||||
<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>
|
||||
Reference in New Issue
Block a user