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,258 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NInput, NButton, NEmpty, NSelect, NCheckboxGroup, NCheckbox, NCard, NPagination, NIcon } from 'naive-ui'
|
||||
import { SearchOutline, FilterOutline, EyeOffOutline } from '@vicons/ionicons5'
|
||||
import { api } from '../../api/client'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useLiteraturePreview } from '../../composables/useLiteraturePreview'
|
||||
import { usePagination } from '../../composables/usePagination'
|
||||
import { trackAction } from '../../composables/useAnalytics'
|
||||
import PageSkeleton from '../../components/common/PageSkeleton.vue'
|
||||
import LiteratureCard from '../../components/literature/LiteratureCard.vue'
|
||||
import LiteraturePreviewDrawer from '../../components/literature/LiteraturePreviewDrawer.vue'
|
||||
import type { LiteratureItem, TagOption, SearchRequestBody } from '../../types'
|
||||
|
||||
const router = useRouter(); const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const query = ref('')
|
||||
const field = ref('all')
|
||||
const sort = ref('date')
|
||||
const results = ref<LiteratureItem[]>([])
|
||||
const loading = ref(false)
|
||||
const searched = ref(false)
|
||||
const savedPmids = ref<Set<number>>(new Set())
|
||||
|
||||
/** 收藏 / 取消收藏 */
|
||||
async function handleSave(item: LiteratureItem) {
|
||||
const wasSaved = savedPmids.value.has(item.pmid)
|
||||
try {
|
||||
if (wasSaved) {
|
||||
await api.delete(`/literature/${item.pmid}/save`)
|
||||
const s = new Set(savedPmids.value)
|
||||
s.delete(item.pmid)
|
||||
savedPmids.value = s
|
||||
toast.success('已取消收藏')
|
||||
} else {
|
||||
await api.post(`/literature/${item.pmid}/save`)
|
||||
savedPmids.value = new Set([...savedPmids.value, item.pmid])
|
||||
toast.success('已收藏')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.apiError(e, wasSaved ? '取消收藏失败' : '收藏失败')
|
||||
}
|
||||
}
|
||||
const yearFromStr = ref(''); const yearToStr = ref('')
|
||||
const datePreset = ref<string | null>(null)
|
||||
const selectedTiers = ref<string[]>([])
|
||||
const selectedTags = ref<string[]>([])
|
||||
/** 所有原始标签(含 L1/L2/L3),用于构建分组 */
|
||||
const allTagsRaw = ref<TagOption[]>([])
|
||||
/** 可折叠分组状态 */
|
||||
const expandedGroups = ref<Record<string, boolean>>({})
|
||||
const showFilters = ref(true)
|
||||
|
||||
/** 按一级分类分组的二级可选标签 */
|
||||
const groupedTags = computed(() => {
|
||||
const l2 = allTagsRaw.value.filter((t: TagOption) => t.level === 2 && t.is_selectable)
|
||||
const l1List = allTagsRaw.value.filter((t: TagOption) => t.level === 1)
|
||||
const groups: { parentId: string; parentName: string; tags: TagOption[] }[] = []
|
||||
for (const l1 of l1List) {
|
||||
const children = l2.filter(t => t.parent_id === String(l1.id))
|
||||
if (children.length) {
|
||||
groups.push({ parentId: String(l1.id), parentName: l1.name_zh, tags: children })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
expandedGroups.value[id] = !expandedGroups.value[id]
|
||||
}
|
||||
|
||||
const { showPreview, previewPmid, openPreview: setPreviewPmid, closePreview } = useLiteraturePreview()
|
||||
const { page, total, goToPage } = usePagination({
|
||||
fetchFn: async (p: number) => {
|
||||
loading.value = true
|
||||
searched.value = true
|
||||
try {
|
||||
// 跟踪搜索行为(仅首次搜索,非翻页)
|
||||
if (p === 1 && query.value.trim()) {
|
||||
trackAction('search', 'search', query.value.trim(), { sort: sort.value })
|
||||
}
|
||||
const body: SearchRequestBody = { query: query.value, field: field.value, page: p, page_size: 20, sort: sort.value }
|
||||
if (yearFromStr.value) body.year_from = Number(yearFromStr.value)
|
||||
if (yearToStr.value) body.year_to = Number(yearToStr.value)
|
||||
if (datePreset.value) {
|
||||
body.date_to = new Date().toISOString().slice(0, 10)
|
||||
const d = new Date()
|
||||
if (datePreset.value === '7d') d.setDate(d.getDate() - 7)
|
||||
else if (datePreset.value === '30d') d.setDate(d.getDate() - 30)
|
||||
else if (datePreset.value === '90d') d.setDate(d.getDate() - 90)
|
||||
else if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
|
||||
body.date_from = d.toISOString().slice(0, 10)
|
||||
}
|
||||
if (selectedTiers.value.length) body.journal_tiers = selectedTiers.value
|
||||
if (selectedTags.value.length) body.tag_ids = selectedTags.value
|
||||
const { data } = await api.post('/features/search/advanced', body)
|
||||
results.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) { toast.apiError(e, '搜索失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
},
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try { const { data } = await api.get('/public/tags'); allTagsRaw.value = data.tags || [] }
|
||||
catch (e) { toast.apiError(e, '加载标签失败') }
|
||||
// 默认只展开第一个分组
|
||||
if (allTagsRaw.value.length) {
|
||||
const firstL1 = allTagsRaw.value.find((t: TagOption) => t.level === 1)
|
||||
if (firstL1) expandedGroups.value[String(firstL1.id)] = true
|
||||
}
|
||||
const tagParam = route.query.tag as string
|
||||
if (tagParam) { selectedTags.value = [tagParam]; goToPage(1) }
|
||||
})
|
||||
|
||||
function goDetail(pmid: number) { router.push(`/app/literature/${pmid}`) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-container" style="display:flex;gap:20px;max-width:1400px;margin:0 auto">
|
||||
<!-- ======== 左侧高级筛选面板 ======== -->
|
||||
<div v-if="showFilters" class="filter-panel">
|
||||
<NCard size="small" title="📊 高级筛选">
|
||||
<!-- 年份范围 -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-secondary)">📅 年份范围</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<NInput v-model:value="yearFromStr" placeholder="起始年" size="tiny" />
|
||||
<NInput v-model:value="yearToStr" placeholder="截止年" size="tiny" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 时间范围 -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-secondary)">📆 时间范围</div>
|
||||
<NSelect v-model:value="datePreset" :options="[
|
||||
{ label: '最近一周', value: '7d' },
|
||||
{ label: '最近一月', value: '30d' },
|
||||
{ label: '最近三月', value: '90d' },
|
||||
{ label: '最近一年', value: '1y' },
|
||||
]" placeholder="选择时间范围" size="tiny" clearable />
|
||||
</div>
|
||||
<!-- 期刊等级 -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-secondary)">📰 期刊等级</div>
|
||||
<NCheckboxGroup v-model:value="selectedTiers">
|
||||
<div v-for="t in [{v:'1',l:'🔴 四大综合'},{v:'2',l:'🟠 肿瘤顶刊'},{v:'3',l:'🟡 专科顶刊'},{v:'4',l:'⚪ 其他SCI'}]" :key="t.v" style="margin:2px 0;display:flex;align-items:center"><NCheckbox :value="t.v" style="font-size:13px">{{ t.l }}</NCheckbox></div>
|
||||
</NCheckboxGroup>
|
||||
</div>
|
||||
<!-- 标签筛选 -->
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-secondary)">🏷️ 标签筛选</div>
|
||||
<NCheckboxGroup v-model:value="selectedTags">
|
||||
<div v-for="g in groupedTags" :key="g.parentId" style="margin-bottom:6px">
|
||||
<div style="display:flex;align-items:center;cursor:pointer;font-size:13px;font-weight:600;color:var(--text-muted);padding:2px 0;user-select:none" @click="toggleGroup(g.parentId)">
|
||||
<span style="display:inline-block;width:12px;font-size:12px;transition:transform .15s" :style="{transform: expandedGroups[g.parentId] ? 'rotate(90deg)' : ''}">▶</span>
|
||||
{{ g.parentName }}
|
||||
<span style="margin-left:4px;font-size:12px;color:var(--text-muted)">({{ g.tags.length }})</span>
|
||||
</div>
|
||||
<template v-if="expandedGroups[g.parentId]">
|
||||
<div v-for="t in g.tags" :key="t.id" style="display:flex;align-items:center;margin:1px 0;padding-left:16px">
|
||||
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh }}</NCheckbox>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCheckboxGroup>
|
||||
</div>
|
||||
<NButton class="sky-btn" block size="small" :loading="loading" @click="goToPage(1)"><template #icon><NIcon size="14"><FilterOutline /></NIcon></template>应用筛选</NButton>
|
||||
<div style="text-align:center;margin-top:6px">
|
||||
<span style="font-size:13px;color:var(--text-muted);cursor:pointer" @click="selectedTags=[];selectedTiers=[];yearFromStr='';yearToStr='';datePreset=null;query=''">重置所有筛选</span>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<!-- ======== 右侧搜索结果 ======== -->
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="search-bar" style="display:flex;margin-bottom:20px">
|
||||
<div class="search-input-wrap"><NInput v-model:value="query" placeholder="搜索标题、摘要、作者、MeSH词..." size="small" @keyup.enter="goToPage(1)" style="height:34px" /></div>
|
||||
<NSelect v-model:value="field" :options="[{label:'全部',value:'all'},{label:'标题',value:'title'},{label:'摘要',value:'abstract'},{label:'作者',value:'author'},{label:'机构',value:'affiliation'},{label:'期刊',value:'journal'}]" size="small" style="width:110px" @update:value="goToPage(1)" />
|
||||
<NSelect v-model:value="sort" :options="[{label:'日期排序',value:'date'},{label:'被引次数',value:'cited'},{label:'相关度',value:'relevance'}]" size="small" style="width:120px" @update:value="goToPage(1)" />
|
||||
<NButton class="sky-btn" size="small" :loading="loading" @click="goToPage(1)"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton>
|
||||
<NButton class="sky-btn" size="small" :ghost="showFilters" @click="showFilters=!showFilters"><template #icon><NIcon size="14"><component :is="showFilters ? EyeOffOutline : FilterOutline" /></NIcon></template>{{ showFilters?'隐藏筛选':'筛选' }}</NButton>
|
||||
</div>
|
||||
|
||||
<p v-if="searched&&total>0" style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">找到 {{ total }} 条结果</p>
|
||||
|
||||
<PageSkeleton :loading="loading && !results.length">
|
||||
<NEmpty v-if="searched&&!loading&&!results.length" description="未找到匹配文献,尝试修改搜索条件" />
|
||||
|
||||
<LiteratureCard
|
||||
v-for="item in results"
|
||||
:key="item.pmid"
|
||||
:item="item"
|
||||
:searchQuery="query"
|
||||
:savedPmids="savedPmids"
|
||||
@detail="(i) => goDetail(i.pmid)"
|
||||
@preview="(item: LiteratureItem) => setPreviewPmid(item.pmid)"
|
||||
@save="handleSave"
|
||||
/>
|
||||
|
||||
<div v-if="searched && total > 0" style="display:flex;justify-content:center;padding:20px">
|
||||
<NPagination
|
||||
:page="page"
|
||||
:item-count="total"
|
||||
:page-size="20"
|
||||
@update:page="goToPage"
|
||||
:simple="true"
|
||||
/>
|
||||
</div>
|
||||
</PageSkeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiteraturePreviewDrawer
|
||||
:show="showPreview"
|
||||
:pmid="previewPmid"
|
||||
@close="closePreview"
|
||||
@go-detail="(item) => router.push('/app/literature/' + item.pmid)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Base layout classes (replacing inline styles for responsive overrides) */
|
||||
.search-container {
|
||||
padding: 24px;
|
||||
}
|
||||
.filter-panel {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-bar {
|
||||
gap: 10px;
|
||||
}
|
||||
.search-input-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
}
|
||||
.filter-panel {
|
||||
width: 100%;
|
||||
}
|
||||
.search-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-input-wrap {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user