633 lines
25 KiB
Vue
633 lines
25 KiB
Vue
<script setup lang="ts">
|
||||
|
|
import { ref, computed, onMounted } from 'vue'
|
|||
|
|
import { useRouter, useRoute } from 'vue-router'
|
|||
|
|
import { useAuthStore } from '../../stores/auth'
|
|||
|
|
import { NInput, NButton, NEmpty, NSelect, NCheckboxGroup, NCheckbox, NPagination, NIcon, NRadioGroup, NRadio } 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 auth = useAuthStore()
|
|||
|
|
|
|||
|
|
// ── 搜索参数 ──
|
|||
|
|
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())
|
|||
|
|
|
|||
|
|
// ── 筛选参数 ──
|
|||
|
|
const yearFromStr = ref(''); const yearToStr = ref('')
|
|||
|
|
const datePreset = ref<string | null>(null)
|
|||
|
|
const selectedTiers = ref<string[]>([])
|
|||
|
|
const selectedTags = ref<string[]>([])
|
|||
|
|
const pubTypes = ref<string[]>([])
|
|||
|
|
const isOa = ref('') // '' = all, 'yes' = OA only, 'no' = abstract only
|
|||
|
|
const language = ref('') // '' = all, or language code like 'en'
|
|||
|
|
const nlmSubsets = ref<string[]>([])
|
|||
|
|
const retracted = ref('') // '' = all, 'yes', 'no', 'only'
|
|||
|
|
const negativeResult = ref('') // '' = all, 'yes', 'no', 'only'
|
|||
|
|
const selectedSpecies = ref<string[]>([])
|
|||
|
|
const selectedSex = ref<string[]>([])
|
|||
|
|
const selectedAge = ref<string[]>([])
|
|||
|
|
|
|||
|
|
// ── 筛选选项数据 ──
|
|||
|
|
const filterOptions = ref<any>(null)
|
|||
|
|
const allTagsRaw = ref<TagOption[]>([])
|
|||
|
|
const expandedGroups = ref<Record<string, boolean>>({})
|
|||
|
|
const showFilters = ref(true)
|
|||
|
|
const yearCounts = ref<{ year: number; count: number }[]>([])
|
|||
|
|
const selectedYear = ref<number | null>(null) // 点击年份高亮
|
|||
|
|
|
|||
|
|
// ── 常用文献类型(前 8 个常驻显示) ──
|
|||
|
|
const MAX_VISIBLE_PUB_TYPES = 8
|
|||
|
|
const showAllPubTypes = ref(false)
|
|||
|
|
|
|||
|
|
// ── 常用语言(前 5 个常驻显示) ──
|
|||
|
|
const MAX_VISIBLE_LANGUAGES = 5
|
|||
|
|
const showAllLanguages = ref(false)
|
|||
|
|
|
|||
|
|
/** 收藏 / 取消收藏 */
|
|||
|
|
async function handleSave(item: LiteratureItem) {
|
|||
|
|
if (!auth.isAuthenticated) {
|
|||
|
|
router.push('/auth/login?redirect=' + encodeURIComponent(route.fullPath))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
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 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 })
|
|||
|
|
}
|
|||
|
|
// 合并所有 tag_ids(MeSH 树 + Species/Sex/Age)
|
|||
|
|
const allTagIds = [
|
|||
|
|
...selectedTags.value,
|
|||
|
|
...selectedSpecies.value,
|
|||
|
|
...selectedSex.value,
|
|||
|
|
...selectedAge.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 (allTagIds.length) body.tag_ids = allTagIds
|
|||
|
|
if (pubTypes.value.length) body.pub_types = pubTypes.value
|
|||
|
|
if (isOa.value === 'yes') body.is_oa = true
|
|||
|
|
else if (isOa.value === 'no') body.is_oa = false
|
|||
|
|
if (language.value) body.language = language.value
|
|||
|
|
if (nlmSubsets.value.length) body.nlm_subsets = nlmSubsets.value
|
|||
|
|
if (retracted.value) body.retracted = retracted.value
|
|||
|
|
if (negativeResult.value) body.negative_result = negativeResult.value
|
|||
|
|
const { data } = await api.post('/features/search/advanced', body)
|
|||
|
|
results.value = data.items || []
|
|||
|
|
total.value = data.total || 0
|
|||
|
|
yearCounts.value = data.year_counts || []
|
|||
|
|
} catch (e) { toast.apiError(e, '搜索失败,请重试') }
|
|||
|
|
finally { loading.value = false }
|
|||
|
|
},
|
|||
|
|
pageSize: 20,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** 从路由 query 恢复搜索参数 */
|
|||
|
|
function restoreFromQuery() {
|
|||
|
|
if (route.query.q) query.value = String(route.query.q)
|
|||
|
|
if (route.query.field) field.value = String(route.query.field)
|
|||
|
|
if (route.query.sort) sort.value = String(route.query.sort)
|
|||
|
|
if (route.query.year_from) yearFromStr.value = String(route.query.year_from)
|
|||
|
|
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
|
|||
|
|
if (route.query.date_from) {
|
|||
|
|
datePreset.value = null
|
|||
|
|
}
|
|||
|
|
if (route.query.tag) selectedTags.value = String(route.query.tag).split(',')
|
|||
|
|
if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',')
|
|||
|
|
if (route.query.pub_type) pubTypes.value = String(route.query.pub_type).split(',')
|
|||
|
|
if (route.query.oa) isOa.value = String(route.query.oa)
|
|||
|
|
if (route.query.lang) language.value = String(route.query.lang)
|
|||
|
|
if (route.query.subset) nlmSubsets.value = String(route.query.subset).split(',')
|
|||
|
|
if (route.query.retracted) retracted.value = String(route.query.retracted)
|
|||
|
|
if (route.query.negative) negativeResult.value = String(route.query.negative)
|
|||
|
|
if (route.query.species) selectedSpecies.value = String(route.query.species).split(',')
|
|||
|
|
if (route.query.sex) selectedSex.value = String(route.query.sex).split(',')
|
|||
|
|
if (route.query.age) selectedAge.value = String(route.query.age).split(',')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadFilterOptions() {
|
|||
|
|
try {
|
|||
|
|
const { data } = await api.get('/features/search/filter-options')
|
|||
|
|
filterOptions.value = data
|
|||
|
|
} catch (e) { toast.apiError(e, '加载筛选选项失败') }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
// 并行加载标签和筛选选项
|
|||
|
|
await Promise.all([
|
|||
|
|
api.get('/public/tags').then(({ data }) => { allTagsRaw.value = data.tags || [] }).catch(() => {}),
|
|||
|
|
loadFilterOptions(),
|
|||
|
|
])
|
|||
|
|
// 默认只展开第一个标签分组
|
|||
|
|
if (allTagsRaw.value.length) {
|
|||
|
|
const firstL1 = allTagsRaw.value.find((t: TagOption) => t.level === 1)
|
|||
|
|
if (firstL1) expandedGroups.value[String(firstL1.id)] = true
|
|||
|
|
}
|
|||
|
|
restoreFromQuery()
|
|||
|
|
// 如果 query 有参数,自动触发搜索
|
|||
|
|
if (route.query.q || route.query.tag) {
|
|||
|
|
await goToPage(1)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function resetAllFilters() {
|
|||
|
|
yearFromStr.value = ''; yearToStr.value = ''
|
|||
|
|
datePreset.value = null
|
|||
|
|
selectedYear.value = null
|
|||
|
|
selectedTiers.value = []
|
|||
|
|
selectedTags.value = []
|
|||
|
|
pubTypes.value = []
|
|||
|
|
isOa.value = ''
|
|||
|
|
language.value = ''
|
|||
|
|
nlmSubsets.value = []
|
|||
|
|
retracted.value = ''
|
|||
|
|
negativeResult.value = ''
|
|||
|
|
selectedSpecies.value = []
|
|||
|
|
selectedSex.value = []
|
|||
|
|
selectedAge.value = []
|
|||
|
|
query.value = ''
|
|||
|
|
field.value = 'all'
|
|||
|
|
sort.value = 'date'
|
|||
|
|
goToPage(1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 点击某一年筛选 */
|
|||
|
|
function clickYear(year: number) {
|
|||
|
|
if (selectedYear.value === year) {
|
|||
|
|
// 取消选择
|
|||
|
|
selectedYear.value = null
|
|||
|
|
yearFromStr.value = ''
|
|||
|
|
yearToStr.value = ''
|
|||
|
|
} else {
|
|||
|
|
selectedYear.value = year
|
|||
|
|
yearFromStr.value = String(year)
|
|||
|
|
yearToStr.value = String(year)
|
|||
|
|
datePreset.value = null
|
|||
|
|
}
|
|||
|
|
goToPage(1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 柱状图最大值 */
|
|||
|
|
const maxYearCount = computed(() => {
|
|||
|
|
if (!yearCounts.value.length) return 1
|
|||
|
|
return Math.max(...yearCounts.value.map(y => y.count), 1)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function goDetail(pmid: number) {
|
|||
|
|
if (auth.isAuthenticated) {
|
|||
|
|
router.push(`/app/literature/${pmid}`)
|
|||
|
|
} else {
|
|||
|
|
router.push(`/literature/${pmid}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 筛选选项访问器 */
|
|||
|
|
const pubTypeOptions = computed(() => filterOptions.value?.pub_types || [])
|
|||
|
|
const languageOptions = computed(() => filterOptions.value?.languages || [])
|
|||
|
|
const nlmSubsetOptions = computed(() => filterOptions.value?.nlm_subsets || [])
|
|||
|
|
const specialTags = computed(() => filterOptions.value?.special_tags || {})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="search-container" style="display:flex;gap:20px;max-width:1400px;margin:0 auto">
|
|||
|
|
<!-- ======== 左侧高级筛选面板 ======== -->
|
|||
|
|
<div v-if="showFilters" class="filter-panel">
|
|||
|
|
<div class="filter-panel-scroll">
|
|||
|
|
<div style="font-size:15px;font-weight:700;margin-bottom:12px;color:var(--text-primary)">筛选条件</div>
|
|||
|
|
|
|||
|
|
<!-- 📅 年份范围 -->
|
|||
|
|
<div class="filter-section">
|
|||
|
|
<div class="filter-title">📅 年份范围</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 class="filter-section">
|
|||
|
|
<div class="filter-title">📆 时间范围</div>
|
|||
|
|
<NSelect v-model:value="datePreset" :options="[
|
|||
|
|
{ label: '最近一周', value: '7d' },
|
|||
|
|
{ label: '最近一月', value: '30d' },
|
|||
|
|
{ label: '最近三月', value: '90d' },
|
|||
|
|
{ label: '最近一年', value: '1y' },
|
|||
|
|
]" placeholder="选择时间范围" size="tiny" clearable />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 📊 Results by year -->
|
|||
|
|
<div class="filter-section" v-if="yearCounts.length && searched">
|
|||
|
|
<div class="filter-title">📊 按年份统计</div>
|
|||
|
|
<div class="year-bar-chart">
|
|||
|
|
<div v-for="yc in yearCounts.slice(0, 20)" :key="yc.year"
|
|||
|
|
class="year-bar-row"
|
|||
|
|
:class="{ active: selectedYear === yc.year }"
|
|||
|
|
@click="clickYear(yc.year)">
|
|||
|
|
<span class="year-bar-label">{{ yc.year }}</span>
|
|||
|
|
<span class="year-bar-track">
|
|||
|
|
<span class="year-bar-fill" :style="{ width: (yc.count / maxYearCount * 100) + '%' }"></span>
|
|||
|
|
</span>
|
|||
|
|
<span class="year-bar-count">{{ yc.count.toLocaleString() }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 📄 文献类型 (Article Type) -->
|
|||
|
|
<div class="filter-section">
|
|||
|
|
<div class="filter-title">📄 文献类型</div>
|
|||
|
|
<NCheckboxGroup v-model:value="pubTypes">
|
|||
|
|
<div v-for="(pt, i) in pubTypeOptions" :key="pt.name" style="margin:2px 0;display:flex;align-items:center">
|
|||
|
|
<NCheckbox v-if="i < MAX_VISIBLE_PUB_TYPES || showAllPubTypes" :value="pt.name" style="font-size:13px">
|
|||
|
|
{{ pt.name }} <span class="filter-count">({{ pt.count.toLocaleString() }})</span>
|
|||
|
|
</NCheckbox>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="pubTypeOptions.length > MAX_VISIBLE_PUB_TYPES" style="margin-top:2px">
|
|||
|
|
<span class="filter-expand-link" @click="showAllPubTypes = !showAllPubTypes">
|
|||
|
|
{{ showAllPubTypes ? '收起' : `更多 (${pubTypeOptions.length - MAX_VISIBLE_PUB_TYPES})` }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</NCheckboxGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 🔓 开放获取 (Text availability) -->
|
|||
|
|
<div class="filter-section">
|
|||
|
|
<div class="filter-title">🔓 文本获取</div>
|
|||
|
|
<NRadioGroup v-model:value="isOa" name="oaGroup">
|
|||
|
|
<div style="display:flex;flex-direction:column;gap:4px">
|
|||
|
|
<NRadio value="" size="small">全部</NRadio>
|
|||
|
|
<NRadio value="yes" size="small">免费全文</NRadio>
|
|||
|
|
<NRadio value="no" size="small">仅摘要</NRadio>
|
|||
|
|
</div>
|
|||
|
|
</NRadioGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 🧬 Species -->
|
|||
|
|
<div v-if="specialTags.species?.length" class="filter-section">
|
|||
|
|
<div class="filter-title">🧬 物种</div>
|
|||
|
|
<NCheckboxGroup v-model:value="selectedSpecies">
|
|||
|
|
<div v-for="t in specialTags.species" :key="t.id" style="margin:2px 0;display:flex;align-items:center">
|
|||
|
|
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
|
|||
|
|
</div>
|
|||
|
|
</NCheckboxGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 🌐 语言 -->
|
|||
|
|
<div class="filter-section">
|
|||
|
|
<div class="filter-title">🌐 语言</div>
|
|||
|
|
<NRadioGroup v-model:value="language" name="langGroup">
|
|||
|
|
<div style="display:flex;flex-direction:column;gap:4px">
|
|||
|
|
<NRadio value="" size="small">全部</NRadio>
|
|||
|
|
<div v-for="(l, i) in languageOptions" :key="l.code" style="margin:2px 0;display:flex;align-items:center">
|
|||
|
|
<NRadio v-if="i < MAX_VISIBLE_LANGUAGES || showAllLanguages" :value="l.code" size="small">
|
|||
|
|
{{ l.code.toUpperCase() }} <span class="filter-count">({{ l.count.toLocaleString() }})</span>
|
|||
|
|
</NRadio>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="languageOptions.length > MAX_VISIBLE_LANGUAGES" style="margin-top:2px">
|
|||
|
|
<span class="filter-expand-link" @click="showAllLanguages = !showAllLanguages">
|
|||
|
|
{{ showAllLanguages ? '收起' : `更多 (${languageOptions.length - MAX_VISIBLE_LANGUAGES})` }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</NRadioGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ♀/♂ Sex -->
|
|||
|
|
<div v-if="specialTags.sex?.length" class="filter-section">
|
|||
|
|
<div class="filter-title">♂/♀ 性别</div>
|
|||
|
|
<NCheckboxGroup v-model:value="selectedSex">
|
|||
|
|
<div v-for="t in specialTags.sex" :key="t.id" style="margin:2px 0;display:flex;align-items:center">
|
|||
|
|
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
|
|||
|
|
</div>
|
|||
|
|
</NCheckboxGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 📰 期刊等级 -->
|
|||
|
|
<div class="filter-section">
|
|||
|
|
<div class="filter-title">📰 期刊等级</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>
|
|||
|
|
|
|||
|
|
<!-- 📋 期刊子集 (Subsets) -->
|
|||
|
|
<div v-if="nlmSubsetOptions.length" class="filter-section">
|
|||
|
|
<div class="filter-title">📋 期刊子集</div>
|
|||
|
|
<NCheckboxGroup v-model:value="nlmSubsets">
|
|||
|
|
<div v-for="s in nlmSubsetOptions" :key="s.code" style="margin:2px 0;display:flex;align-items:center">
|
|||
|
|
<NCheckbox :value="s.code" style="font-size:13px">
|
|||
|
|
{{ s.label }} <span class="filter-count">({{ s.count.toLocaleString() }})</span>
|
|||
|
|
</NCheckbox>
|
|||
|
|
</div>
|
|||
|
|
</NCheckboxGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 🏷️ 标签筛选 (MeSH) -->
|
|||
|
|
<div class="filter-section">
|
|||
|
|
<div class="filter-title">🏷️ MeSH 标签</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>
|
|||
|
|
|
|||
|
|
<!-- 👶👨👴 年龄组 -->
|
|||
|
|
<div v-if="specialTags.age?.length" class="filter-section">
|
|||
|
|
<div class="filter-title">👶 年龄组</div>
|
|||
|
|
<NCheckboxGroup v-model:value="selectedAge">
|
|||
|
|
<div v-for="t in specialTags.age" :key="t.id" style="margin:2px 0;display:flex;align-items:center">
|
|||
|
|
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
|
|||
|
|
</div>
|
|||
|
|
</NCheckboxGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ⚙️ 其它 (撤稿/阴性结果) -->
|
|||
|
|
<div class="filter-section">
|
|||
|
|
<div class="filter-title">⚙️ 其它</div>
|
|||
|
|
<div style="margin-bottom:8px">
|
|||
|
|
<div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:4px">撤稿</div>
|
|||
|
|
<NRadioGroup v-model:value="retracted" name="retractedGroup">
|
|||
|
|
<div style="display:flex;flex-direction:column;gap:4px">
|
|||
|
|
<NRadio value="" size="small">全部</NRadio>
|
|||
|
|
<NRadio value="no" size="small">未撤稿</NRadio>
|
|||
|
|
<NRadio value="only" size="small">仅撤稿</NRadio>
|
|||
|
|
</div>
|
|||
|
|
</NRadioGroup>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:4px">结果类型</div>
|
|||
|
|
<NRadioGroup v-model:value="negativeResult" name="negativeGroup">
|
|||
|
|
<div style="display:flex;flex-direction:column;gap:4px">
|
|||
|
|
<NRadio value="" size="small">全部</NRadio>
|
|||
|
|
<NRadio value="no" size="small">阳性结果</NRadio>
|
|||
|
|
<NRadio value="only" size="small">阴性结果</NRadio>
|
|||
|
|
</div>
|
|||
|
|
</NRadioGroup>
|
|||
|
|
</div>
|
|||
|
|
</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="resetAllFilters">重置所有筛选</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</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) => goDetail(item.pmid)"
|
|||
|
|
/>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
/* Base layout classes */
|
|||
|
|
.search-container {
|
|||
|
|
padding: 24px;
|
|||
|
|
}
|
|||
|
|
.filter-panel {
|
|||
|
|
width: 270px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.filter-panel-scroll {
|
|||
|
|
max-height: calc(100vh - 100px);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding-right: 4px;
|
|||
|
|
}
|
|||
|
|
.filter-panel-scroll::-webkit-scrollbar { width: 4px; }
|
|||
|
|
.filter-panel-scroll::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
|
|||
|
|
|
|||
|
|
.filter-section {
|
|||
|
|
margin-bottom: 14px;
|
|||
|
|
}
|
|||
|
|
.filter-title {
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
.filter-count {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
.filter-expand-link {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--kw-pill-color);
|
|||
|
|
cursor: pointer;
|
|||
|
|
user-select: none;
|
|||
|
|
}
|
|||
|
|
html.dark .filter-expand-link { color: #6ab0e0; }
|
|||
|
|
.filter-expand-link:hover { text-decoration: underline; }
|
|||
|
|
|
|||
|
|
/* Results by year 柱状图 */
|
|||
|
|
.year-bar-chart {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 3px;
|
|||
|
|
}
|
|||
|
|
.year-bar-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
padding: 2px 4px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
transition: background .1s;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
.year-bar-row:hover { background: var(--bg-hover); }
|
|||
|
|
.year-bar-row.active { background: color-mix(in srgb, var(--kw-pill-color) 12%, transparent); }
|
|||
|
|
html.dark .year-bar-row.active { background: color-mix(in srgb, #6ab0e0 18%, transparent); }
|
|||
|
|
.year-bar-label {
|
|||
|
|
width: 36px;
|
|||
|
|
text-align: right;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-variant-numeric: tabular-nums;
|
|||
|
|
}
|
|||
|
|
.year-bar-row.active .year-bar-label {
|
|||
|
|
color: var(--kw-pill-color);
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
html.dark .year-bar-row.active .year-bar-label { color: #6ab0e0; }
|
|||
|
|
.year-bar-track {
|
|||
|
|
flex: 1;
|
|||
|
|
height: 10px;
|
|||
|
|
background: var(--bg-hover);
|
|||
|
|
border-radius: 5px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
min-width: 40px;
|
|||
|
|
}
|
|||
|
|
.year-bar-fill {
|
|||
|
|
display: block;
|
|||
|
|
height: 100%;
|
|||
|
|
border-radius: 5px;
|
|||
|
|
background: var(--kw-pill-color);
|
|||
|
|
opacity: .6;
|
|||
|
|
transition: width .2s, opacity .1s;
|
|||
|
|
}
|
|||
|
|
.year-bar-row:hover .year-bar-fill { opacity: .85; }
|
|||
|
|
.year-bar-row.active .year-bar-fill { opacity: 1; }
|
|||
|
|
html.dark .year-bar-fill { background: #6ab0e0; }
|
|||
|
|
.year-bar-count {
|
|||
|
|
width: 48px;
|
|||
|
|
text-align: right;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
font-size: 11px;
|
|||
|
|
font-variant-numeric: tabular-nums;
|
|||
|
|
}
|
|||
|
|
.year-bar-row.active .year-bar-count {
|
|||
|
|
color: var(--kw-pill-color);
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
html.dark .year-bar-row.active .year-bar-count { color: #6ab0e0; }
|
|||
|
|
|
|||
|
|
.search-bar {
|
|||
|
|
gap: 10px;
|
|||
|
|
}
|
|||
|
|
.search-input-wrap {
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
.search-container {
|
|||
|
|
flex-direction: column;
|
|||
|
|
padding: 12px;
|
|||
|
|
}
|
|||
|
|
.filter-panel {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
.filter-panel-scroll {
|
|||
|
|
max-height: none;
|
|||
|
|
}
|
|||
|
|
.search-bar {
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
.search-input-wrap {
|
|||
|
|
flex: 1 1 100%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|