Files
backend/frontend/src/views/app/SearchView.vue
T
34047007@qq.com 5f69277eba
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
feat: PubMed搜索合规审计 Phase 5-7 全部修复
P1-2: [SB][STAT][UID][DEP][MESH]字段支持
P1-4: [MH:noexp]解析+抑制树展开
P3: 前端date_to/P3-1、CJK高亮/P3-3、extractPlainText/P3-4、precision_mode移除/P3-6、loadMore sort/P3-5
P4-1: 词数上限50→100
P4-2: retracted参数清理
2026-07-27 09:03:19 +08:00

1140 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { NInput, NButton, NEmpty, NSelect, NCheckboxGroup, NCheckbox, NPagination, NIcon, NRadioGroup, NRadio, NSlider, NModal, NPopover } from 'naive-ui'
import { SearchOutline, FilterOutline, EyeOffOutline, SettingsOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useToast } from '../../composables/useToast'
import { useLiteraturePreview } from '../../composables/useLiteraturePreview'
import { usePagination } from '../../composables/usePagination'
import { useDisplaySettings } from '../../composables/useDisplaySettings'
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, SavedFilter } from '../../types'
const router = useRouter(); const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const displaySettings = useDisplaySettings()
// ── 搜索参数 ──
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 languages = ref<string[]>([]) // [] = all, or language codes 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[]>([])
// ── PubMed 筛选器状态 ──
const hasAbstract = ref(false)
const isFreeFullText = ref(false)
const hasFullText = ref(false)
const hasAssociatedData = ref(false)
const medlineOnly = ref(false)
const excludePreprints = ref(false)
const precisionMode = ref('majr') // 'majr' | 'mesh'
// ── 筛选选项数据 ──
const filterOptions = ref<any>(null)
const allTagsRaw = ref<TagOption[]>([])
const expandedGroups = ref<Record<string, boolean>>({})
const showFilters = ref(localStorage.getItem('search:showFilters') !== 'false')
const yearCounts = ref<{ year: number; count: number }[]>([])
const showCustomYear = ref(false) // 自定义年份范围展开
const pageSize = ref(Number(localStorage.getItem('search:pageSize')) || 20)
// 从 URL 恢复的页码(syncSearchToUrl 写入)
const restoredPage = ref(1)
// ── Keyset 游标分页(sort=date 时使用,跳过 COUNT ──
const keysetPage = ref(1)
const keysetCursors = ref<Array<{cursor_date: string, cursor_id: string} | null>>([null])
const keysetHasMore = ref(false)
const minYear = computed(() => yearCounts.value.length ? Math.min(...yearCounts.value.map(y => y.year)) : 2000)
const maxYear = computed(() => yearCounts.value.length ? Math.max(...yearCounts.value.map(y => y.year)) : 2030)
/** 柱状图最大值 */
const maxYearCount = computed(() => {
if (!yearCounts.value.length) return 1
return Math.max(...yearCounts.value.map(y => y.count), 1)
})
/** slider 值 = 与 yearFromStr/yearToStr 同步 */
const yearSliderValue = computed((): [number, number] => {
const from = yearFromStr.value ? Number(yearFromStr.value) : minYear.value
const to = yearToStr.value ? Number(yearToStr.value) : maxYear.value
return [from, to]
})
let _sliderTimer: ReturnType<typeof setTimeout> | null = null
function onYearSliderChange(val: any) {
yearFromStr.value = String(val[0])
yearToStr.value = String(val[1])
datePreset.value = null
if (_sliderTimer) clearTimeout(_sliderTimer)
_sliderTimer = setTimeout(() => goToPage(1), 250)
}
onUnmounted(() => {
if (_sliderTimer) clearTimeout(_sliderTimer)
searchAbortController.value.abort()
})
// ── 常用文献类型(前 8 个常驻显示) ──
const MAX_VISIBLE_PUB_TYPES = 5
const showPubTypeModal = ref(false)
// ── 常用语言(前 5 个常驻显示) ──
const MAX_VISIBLE_LANGUAGES = 5
const showLangModal = ref(false)
// ── AGE 前 5 个常驻显示 ──
const MAX_VISIBLE_AGE = 5
const showAgeModal = ref(false)
// ── My Custom Filters ──
const savedFilters = ref<SavedFilter[]>([])
const showFilterEditModal = ref(false)
const editFilterId = ref<string | null>(null)
const editName = ref('')
const editQuery = ref('')
async function loadSavedFilters() {
if (!auth.isAuthenticated) return
try {
const { data } = await api.get('/features/search/saved-filters')
savedFilters.value = data.filters || []
} catch { /* ignore */ }
}
function applySavedFilter(f: SavedFilter) {
if (!auth.isAuthenticated) {
router.push('/auth/login?redirect=' + encodeURIComponent(route.fullPath))
return
}
query.value = f.query_string
goToPage(1)
}
function openEditFilter(f: SavedFilter) {
editFilterId.value = f.id
editName.value = f.name
editQuery.value = f.query_string
showFilterEditModal.value = true
}
function openNewFilter() {
editFilterId.value = null
editName.value = ''
editQuery.value = ''
}
watch(showFilterEditModal, (val) => {
if (!val) {
openNewFilter()
}
})
async function saveFilter() {
if (!editName.value.trim() || !editQuery.value.trim()) return
try {
if (editFilterId.value) {
await api.put(`/features/search/saved-filters/${editFilterId.value}`, {
name: editName.value.trim(),
query_string: editQuery.value.trim(),
})
} else {
await api.post('/features/search/saved-filters', {
name: editName.value.trim(),
query_string: editQuery.value.trim(),
})
}
showFilterEditModal.value = false
openNewFilter()
await loadSavedFilters()
} catch (e) { toast.apiError(e, '保存失败') }
}
async function deleteSavedFilter(id: string) {
if (!window.confirm('确定删除此筛选器?')) return
try {
await api.delete(`/features/search/saved-filters/${id}`)
await loadSavedFilters()
} catch (e) { toast.apiError(e, '删除失败') }
}
async function moveFilterDown(index: number) {
if (index >= savedFilters.value.length - 1) return
const arr = savedFilters.value.slice()
const a = arr[index]; const b = arr[index + 1]
if (!a || !b) return
arr[index] = b; arr[index + 1] = a
savedFilters.value = arr
try {
await api.put('/features/search/saved-filters/reorder',
arr.map((f, i) => ({ id: f.id, sort_order: i }))
)
} catch (e) {
await loadSavedFilters() // 回滚到服务端状态
toast.apiError(e, '排序失败')
}
}
async function moveFilterUp(index: number) {
if (index <= 0) return
const arr = savedFilters.value.slice()
const a = arr[index]; const b = arr[index - 1]
if (!a || !b) return
arr[index] = b; arr[index - 1] = a
savedFilters.value = arr
try {
await api.put('/features/search/saved-filters/reorder',
arr.map((f, i) => ({ id: f.id, sort_order: i }))
)
} catch (e) {
await loadSavedFilters() // 回滚到服务端状态
toast.apiError(e, '排序失败')
}
}
/** 收藏 / 取消收藏 */
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 searchAbortController = ref(new AbortController())
const searchGeneration = ref(0)
const { page, total, goToPage } = usePagination({
fetchFn: async (p: number) => {
// 取消上一个进行中的请求,避免竞态
searchAbortController.value.abort()
searchAbortController.value = new AbortController()
const signal = searchAbortController.value.signal
const gen = ++searchGeneration.value
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, page: p, page_size: pageSize.value, sort: sort.value,
}
if (field.value !== 'all') body.field = field.value
// P3-6: precision_mode 不再发送(后端已忽略)
// ── Keyset 游标分页(sort=date 时跳过 COUNT,纯翻页模式) ──
const useKeyset = sort.value === 'date'
if (useKeyset) {
if (p === 1) {
delete (body as any).page
keysetCursors.value = [null]; keysetPage.value = 1
} else {
const entry = keysetCursors.value[p]
if (entry) {
delete (body as any).page
body.cursor_date = entry.cursor_date
body.cursor_id = entry.cursor_id
}
// cursor 不存在时保留 body.page,回退到 offset 分页
}
}
// 年份 / 日期
const yf = Number(yearFromStr.value)
const yt = Number(yearToStr.value)
if (yearFromStr.value && !isNaN(yf) && yf >= 1900 && yf <= 2100) body.year_from = yf
if (yearToStr.value && !isNaN(yt) && yt >= 1900 && yt <= 2100) body.year_to = yt
if (datePreset.value && datePreset.value !== 'custom') {
body.date_to = new Date().toISOString().slice(0, 10)
const d = new Date()
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
body.date_from = d.toISOString().slice(0, 10)
}
// Text Availability
if (hasAbstract.value) body.has_abstract = true
if (isFreeFullText.value) body.is_free_full_text = true
if (hasFullText.value) body.has_full_text = true
// Article Attribute
if (hasAssociatedData.value) body.has_associated_data = true
// Article Type
if (pubTypes.value.length) body.pub_types = pubTypes.value
// Language
if (languages.value.length) body.languages = languages.value
// Species / Sex / Agemesh_ui 值)
if (selectedSpecies.value.length) body.species = selectedSpecies.value
if (selectedSex.value.length) body.sex = selectedSex.value
if (selectedAge.value.length) body.age = selectedAge.value
// Journal Categories
if (medlineOnly.value) body.medline_only = true
if (excludePreprints.value) body.exclude_preprints = true
if (nlmSubsets.value.length) body.nlm_subsets = nlmSubsets.value
// 其他
if (selectedTiers.value.length) body.journal_tiers = selectedTiers.value
if (selectedTags.value.length) body.tag_ids = selectedTags.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, { signal })
results.value = data.items || []
if (useKeyset) {
// 第一页走 COUNT,存下总数;后面沿用第一页的数值
if (p === 1) total.value = data.total || 0
keysetHasMore.value = data.has_more || false
keysetPage.value = p
// 从当前页最后一条保存游标,供下一页使用
const items = data.items || []
if (items.length > 0) {
const last = items[items.length - 1]
keysetCursors.value[p + 1] = {
cursor_date: last.article_date || last.pub_date || '',
cursor_id: last.id,
}
}
} else {
total.value = data.total || 0
}
yearCounts.value = data.year_counts || []
syncSearchToUrl()
} catch (e: any) {
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
toast.apiError(e, '搜索失败,请重试')
}
finally { if (gen === searchGeneration.value) loading.value = false }
},
pageSize: pageSize.value,
})
/** 从路由 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_preset && ['1y','5y','10y'].includes(String(route.query.date_preset))) {
datePreset.value = String(route.query.date_preset)
} else if (route.query.date_from) {
datePreset.value = null
const df = String(route.query.date_from)
if (df.length >= 4) yearFromStr.value = df.slice(0, 4)
}
if (route.query.date_to) {
datePreset.value = null
const dt = String(route.query.date_to)
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
}
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.lang) languages.value = String(route.query.lang).split(',')
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(',')
if (route.query.has_abstract) hasAbstract.value = route.query.has_abstract === 'true'
if (route.query.free_full_text) isFreeFullText.value = route.query.free_full_text === 'true'
if (route.query.full_text) hasFullText.value = route.query.full_text === 'true'
if (route.query.associated_data) hasAssociatedData.value = route.query.associated_data === 'true'
if (route.query.medline_only) medlineOnly.value = route.query.medline_only === 'true'
if (route.query.exclude_preprints) excludePreprints.value = route.query.exclude_preprints === 'true'
if (route.query.precision) precisionMode.value = String(route.query.precision)
if (route.query.p) restoredPage.value = parseInt(String(route.query.p)) || 1
// keyset 游标不能从 URL 恢复 → page>1 回退到首页
if (sort.value === 'date' && restoredPage.value > 1) restoredPage.value = 1
}
// 选择预设(1y/5y/10y)时清除自定义年份并自动搜索
watch(datePreset, (val) => {
if (val && val !== 'custom') {
yearFromStr.value = ''
yearToStr.value = ''
if (searched.value) goToPage(1)
}
})
// pageSize 变化时持久化并重新搜索
watch(pageSize, (v) => {
localStorage.setItem('search:pageSize', String(v))
if (searched.value) goToPage(1)
})
// 筛选面板状态持久化
watch(showFilters, (v) => {
localStorage.setItem('search:showFilters', String(v))
})
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(),
loadSavedFilters(),
])
// 默认只展开第一个标签分组
if (allTagsRaw.value.length) {
const firstL1 = allTagsRaw.value.find((t: TagOption) => t.level === 1)
if (firstL1) expandedGroups.value[String(firstL1.id)] = true
}
restoreFromQuery()
// 进入搜索页自动触发一次搜索,恢复保存的页码
await goToPage(restoredPage.value)
})
/** 同步当前搜索参数到 URL query */
function syncSearchToUrl() {
const q: Record<string, string> = {}
if (query.value) q.q = query.value
if (field.value !== 'all') q.field = field.value
if (sort.value !== 'date') q.sort = sort.value
if (yearFromStr.value) q.year_from = yearFromStr.value
if (yearToStr.value) q.year_to = yearToStr.value
if (datePreset.value && datePreset.value !== 'custom') {
q.date_preset = datePreset.value
// 同时保存计算出的 date_from/date_to,使分享的 URL 能直接恢复
q.date_to = new Date().toISOString().slice(0, 10)
const d = new Date()
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
q.date_from = d.toISOString().slice(0, 10)
}
if (selectedTags.value.length) q.tag = selectedTags.value.join(',')
if (selectedTiers.value.length) q.tier = selectedTiers.value.join(',')
if (pubTypes.value.length) q.pub_type = pubTypes.value.join(',')
if (languages.value.length) q.lang = languages.value.join(',')
if (nlmSubsets.value.length) q.subset = nlmSubsets.value.join(',')
if (retracted.value) q.retracted = retracted.value
if (negativeResult.value) q.negative = negativeResult.value
if (selectedSpecies.value.length) q.species = selectedSpecies.value.join(',')
if (selectedSex.value.length) q.sex = selectedSex.value.join(',')
if (selectedAge.value.length) q.age = selectedAge.value.join(',')
if (hasAbstract.value) q.has_abstract = 'true'
if (isFreeFullText.value) q.free_full_text = 'true'
if (hasFullText.value) q.full_text = 'true'
if (hasAssociatedData.value) q.associated_data = 'true'
if (medlineOnly.value) q.medline_only = 'true'
if (excludePreprints.value) q.exclude_preprints = 'true'
if (precisionMode.value !== 'majr') q.precision = precisionMode.value
// 页码持久化到 URL(不同排序下使用各自的分页)
const currentPage = sort.value === 'date' ? keysetPage.value : page.value
if (currentPage > 1) q.p = String(currentPage)
router.replace({ query: q }).catch(() => {})
}
function resetAllFilters() {
yearFromStr.value = ''; yearToStr.value = ''
datePreset.value = null
selectedTiers.value = []
selectedTags.value = []
pubTypes.value = []
languages.value = []
nlmSubsets.value = []
retracted.value = ''
negativeResult.value = ''
selectedSpecies.value = []
selectedSex.value = []
selectedAge.value = []
hasAbstract.value = false
isFreeFullText.value = false
hasFullText.value = false
hasAssociatedData.value = false
medlineOnly.value = false
excludePreprints.value = false
precisionMode.value = 'majr'
query.value = ''
field.value = 'all'
sort.value = 'date'
goToPage(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="max-width:1200px;margin:0 auto;padding:32px 24px">
<!-- ======== 左侧筛选面板PubMed 顺序 ======== -->
<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>
<!-- My Custom Filters -->
<div class="filter-section">
<div class="filter-title">MY CUSTOM FILTERS</div>
<div v-if="auth.isAuthenticated && savedFilters.length">
<div v-for="f in savedFilters" :key="f.id"
style="display:flex;align-items:center;gap:4px;padding:3px 0;font-size:13px;cursor:pointer"
@click="applySavedFilter(f)">
<span style="color:var(--kw-pill-color);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px">{{ f.name }}</span>
<span style="font-size:11px;color:var(--text-muted);cursor:pointer;flex-shrink:0" @click.stop="openEditFilter(f)"></span>
</div>
</div>
<div style="margin-top:4px">
<span class="filter-expand-link" @click="auth.isAuthenticated ? (openNewFilter(), showFilterEditModal = true) : router.push('/auth/login?redirect=' + encodeURIComponent(route.fullPath))">Edit custom filters</span>
</div>
</div>
<!-- 1. RESULTS BY YEAR面积图 + 双向滑块 -->
<div class="filter-section" v-if="yearCounts.length && searched">
<div class="filter-title">RESULTS BY YEAR</div>
<div class="histogram-chart">
<div v-for="yc in yearCounts" :key="yc.year" class="histogram-bar-wrap"
:class="{ muted: yearSliderValue && (yc.year < yearSliderValue[0] || yc.year > yearSliderValue[1]) }">
<div class="histogram-bar" :style="{ height: (yc.count / maxYearCount * 100) + '%' }"
:title="`${yc.year}: ${yc.count.toLocaleString()}篇`"></div>
</div>
</div>
<NSlider
range
:min="minYear"
:max="maxYear"
:step="1"
:value="yearSliderValue"
@update:value="onYearSliderChange"
:format-tooltip="(v: number) => String(v)"
/>
<div class="histogram-slider-labels">
<span>{{ yearSliderValue[0] }}</span>
<span>{{ yearSliderValue[1] }}</span>
</div>
<div style="margin-top:6px">
<span class="filter-expand-link" @click="showCustomYear = !showCustomYear">
{{ showCustomYear ? '收起' : '自定义年份范围' }}
</span>
<div v-if="showCustomYear" style="display:flex;gap:6px;margin-top:6px">
<NInput v-model:value="yearFromStr" placeholder="起始年" size="tiny" :maxlength="4" />
<span style="color:var(--text-muted);line-height:28px">—</span>
<NInput v-model:value="yearToStr" placeholder="截止年" size="tiny" :maxlength="4" />
<NButton size="tiny" :disabled="loading" @click="goToPage(1)" style="flex-shrink:0">确定</NButton>
</div>
</div>
</div>
<!-- 2. PUBLICATION DATE -->
<div class="filter-section">
<div class="filter-title">PUBLICATION DATE</div>
<NRadioGroup v-model:value="datePreset">
<div style="display:flex;flex-direction:column;gap:4px">
<NRadio value="1y" size="small">1 year</NRadio>
<NRadio value="5y" size="small">5 years</NRadio>
<NRadio value="10y" size="small">10 years</NRadio>
<NRadio value="custom" size="small">Custom Range</NRadio>
</div>
</NRadioGroup>
<div v-if="datePreset === 'custom'" style="display:flex;gap:6px;margin-top:6px">
<NInput v-model:value="yearFromStr" placeholder="起始年" size="tiny" :maxlength="4" />
<span style="color:var(--text-muted);line-height:28px">—</span>
<NInput v-model:value="yearToStr" placeholder="截止年" size="tiny" :maxlength="4" />
<NButton size="tiny" :disabled="loading" @click="goToPage(1)" style="flex-shrink:0">确定</NButton>
</div>
</div>
<!-- 3. TEXT AVAILABILITY -->
<div class="filter-section">
<div class="filter-title">TEXT AVAILABILITY</div>
<div style="display:flex;flex-direction:column;gap:4px">
<NCheckbox v-model:checked="hasAbstract" style="font-size:13px">
Abstract <span class="filter-count" v-if="filterOptions?.text_availability">({{ filterOptions.text_availability.has_abstract }})</span>
</NCheckbox>
<NCheckbox v-model:checked="isFreeFullText" style="font-size:13px">
Free full text <span class="filter-count" v-if="filterOptions?.text_availability">({{ filterOptions.text_availability.is_free_full_text }})</span>
</NCheckbox>
<NCheckbox v-model:checked="hasFullText" style="font-size:13px">
Full text <span class="filter-count" v-if="filterOptions?.text_availability">({{ filterOptions.text_availability.has_full_text }})</span>
</NCheckbox>
</div>
</div>
<!-- 4. ARTICLE ATTRIBUTE -->
<div class="filter-section">
<div class="filter-title">ARTICLE ATTRIBUTE</div>
<div style="display:flex;flex-direction:column;gap:4px">
<NCheckbox v-model:checked="hasAssociatedData" style="font-size:13px">
Associated data
</NCheckbox>
</div>
</div>
<!-- 5. ARTICLE TYPE -->
<div class="filter-section">
<div class="filter-title">ARTICLE TYPE</div>
<NCheckboxGroup v-model:value="pubTypes">
<template v-for="(pt, i) in pubTypeOptions" :key="pt.name">
<div v-if="i < MAX_VISIBLE_PUB_TYPES" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="pt.name" style="font-size:13px">
{{ pt.name }} <span class="filter-count">({{ pt.count.toLocaleString() }})</span>
</NCheckbox>
</div>
</template>
<div v-if="pubTypeOptions.length > MAX_VISIBLE_PUB_TYPES" style="margin-top:2px">
<span class="filter-expand-link" @click="showPubTypeModal = true">
更多 ({{ pubTypeOptions.length - MAX_VISIBLE_PUB_TYPES }})
</span>
</div>
</NCheckboxGroup>
</div>
<!-- 6. ARTICLE LANGUAGE -->
<div class="filter-section">
<div class="filter-title">ARTICLE LANGUAGE</div>
<NCheckboxGroup v-model:value="languages">
<div style="display:flex;flex-direction:column;gap:4px">
<template v-for="(l, i) in languageOptions" :key="l.code">
<div v-if="i < MAX_VISIBLE_LANGUAGES" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="l.code" size="small">
{{ l.code.toUpperCase() }} <span class="filter-count">({{ l.count.toLocaleString() }})</span>
</NCheckbox>
</div>
</template>
<div v-if="languageOptions.length > MAX_VISIBLE_LANGUAGES" style="margin-top:2px">
<span class="filter-expand-link" @click="showLangModal = true">
更多 ({{ languageOptions.length - MAX_VISIBLE_LANGUAGES }})
</span>
</div>
</div>
</NCheckboxGroup>
</div>
<!-- 7. SPECIES -->
<div v-if="specialTags.species?.length" class="filter-section">
<div class="filter-title">SPECIES</div>
<NCheckboxGroup v-model:value="selectedSpecies">
<div v-for="t in specialTags.species" :key="t.mesh_ui" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="t.mesh_ui" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<!-- 8. SEX -->
<div v-if="specialTags.sex?.length" class="filter-section">
<div class="filter-title">SEX</div>
<NCheckboxGroup v-model:value="selectedSex">
<div v-for="t in specialTags.sex" :key="t.mesh_ui" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="t.mesh_ui" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<!-- 9. AGE -->
<div v-if="specialTags.age?.length" class="filter-section">
<div class="filter-title">AGE</div>
<NCheckboxGroup v-model:value="selectedAge">
<div v-for="t in specialTags.age.slice(0, MAX_VISIBLE_AGE)" :key="t.key" style="margin:2px 0;display:flex;align-items:center"
:style="t.indent === 0 ? {fontWeight:600,marginTop:'6px'} : {}">
<NCheckbox :value="t.key" style="font-size:13px">{{ t.label }}</NCheckbox>
</div>
<div v-if="specialTags.age.length > MAX_VISIBLE_AGE" style="margin-top:2px">
<span class="filter-expand-link" @click="showAgeModal = true">
更多 ({{ specialTags.age.length - MAX_VISIBLE_AGE }})
</span>
</div>
</NCheckboxGroup>
</div>
<!-- 10. OTHER -->
<div class="filter-section">
<div class="filter-title">OTHER</div>
<!-- MEDLINE + Exclude Preprints -->
<div style="margin-bottom:8px">
<NCheckbox v-model:checked="medlineOnly" style="font-size:13px">MEDLINE</NCheckbox>
</div>
<div style="margin-bottom:8px">
<NCheckbox v-model:checked="excludePreprints" style="font-size:13px">Exclude preprints</NCheckbox>
</div>
<!-- 期刊子集 -->
<div v-if="nlmSubsetOptions.length" style="margin-bottom:8px">
<div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:4px">期刊子集</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>
<!-- 撤稿 -->
<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 style="margin-bottom:8px">
<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 style="margin-bottom:8px">
<div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:4px">期刊等级</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>
<!-- 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>
<NButton class="sky-btn" block size="small" :loading="loading" :disabled="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">
<div class="search-input-wrap"><NInput v-model:value="query" placeholder='搜索标题、摘要、作者、MeSH词... 支持PubMed语法如 "lung cancer"[TI]' size="small" clearable @keyup.enter="goToPage(1)" /></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" />
<NButton class="sky-btn" size="small" :loading="loading" :disabled="loading" @click="goToPage(1)"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton>
<NButton class="sky-btn" :ghost="showFilters" size="small" @click="showFilters=!showFilters"><template #icon><NIcon size="14"><component :is="showFilters ? EyeOffOutline : FilterOutline" /></NIcon></template>{{ showFilters?'隐藏筛选':'筛选' }}</NButton>
</div>
<!-- 结果工具栏:计数 + 排序 + 显示设置 -->
<div v-if="searched" class="results-toolbar">
<span class="results-count">
找到 {{ total }} 条结果
</span>
<div class="results-toolbar-right">
<span class="toolbar-label">排序:</span>
<NSelect v-model:value="sort" :options="[
{label:'Best Match',value:'best_match'},
{label:'Most Recent',value:'date'},
{label:'Publication date',value:'pub_date'},
{label:'First author',value:'first_author'},
{label:'Journal',value:'journal'},
]" size="tiny" style="width:150px" @update:value="goToPage(1)" />
<span class="toolbar-label">每页:</span>
<NSelect v-model:value="pageSize" :options="[
{label:'10条/页',value:10},
{label:'20条/页',value:20},
{label:'50条/页',value:50},
]" size="tiny" style="width:100px" />
<NPopover trigger="click" placement="bottom-end" :width="200">
<template #trigger>
<NButton size="tiny" quaternary title="显示设置"><template #icon><NIcon size="14"><SettingsOutline /></NIcon></template></NButton>
</template>
<div style="font-size:13px;font-weight:600;margin-bottom:8px">显示设置</div>
<div style="display:flex;flex-direction:column;gap:4px;font-size:13px">
<NCheckbox v-model:checked="displaySettings.showAuthors">作者</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showAffiliation">机构</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showJournal">期刊+分区</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showPmid">PMID</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showDoi">DOI</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showStudyTypes">研究类型+OA+NCT</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showTags">MeSH 标签</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showCitedBy">被引次数</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showAiSummary">AI 摘要</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showBadges">徽章</NCheckbox>
<NCheckbox v-model:checked="displaySettings.showActions">操作按钮</NCheckbox>
</div>
</NPopover>
</div>
</div>
<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"
:displaySettings="displaySettings"
@detail="(i) => goDetail(i.pmid)"
@preview="(item: LiteratureItem) => setPreviewPmid(item.pmid)"
@save="handleSave"
/>
<!-- Keyset 分页(sort=date,不做 COUNT,纯翻页) -->
<div v-if="searched && sort === 'date' && results.length > 0" style="display:flex;justify-content:center;align-items:center;gap:12px;padding:20px">
<NButton size="small" :disabled="keysetPage <= 1 || loading" @click="goToPage(keysetPage - 1)">← 上一页</NButton>
<span style="font-size:13px;color:var(--text-muted)">第 {{ keysetPage }} 页</span>
<NButton size="small" :disabled="!keysetHasMore || loading" @click="goToPage(keysetPage + 1)">下一页 →</NButton>
</div>
<!-- Offset 分页(其他排序,需 COUNT -->
<div v-else-if="searched && total > 0" style="display:flex;justify-content:center;padding:20px">
<NPagination
:page="page"
:item-count="total"
:page-size="pageSize"
@update:page="goToPage"
:simple="true"
/>
</div>
</PageSkeleton>
</div>
</div>
<NModal v-model:show="showPubTypeModal" :mask-closable="true" preset="card" :segmented="{content:true,footer:true}" :bordered="true" style="width:95vw;max-width:900px">
<template #header><div style="font-size:16px;font-weight:700">选择文献类型</div></template>
<div style="max-height:60vh;overflow-y:auto;padding:8px 0">
<NCheckboxGroup v-model:value="pubTypes">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px 16px">
<div v-for="pt in pubTypeOptions" :key="pt.name" style="margin:3px 0;display:flex;align-items:center">
<NCheckbox :value="pt.name" style="font-size:13px">
{{ pt.name }} <span class="filter-count">({{ pt.count.toLocaleString() }})</span>
</NCheckbox>
</div>
</div>
</NCheckboxGroup>
</div>
<template #footer>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:13px;color:var(--text-muted)">已选择 {{ pubTypes.length }} 项</span>
<NButton size="small" type="primary" @click="showPubTypeModal = false; goToPage(1)">确定</NButton>
</div>
</template>
</NModal>
<NModal v-model:show="showLangModal" :mask-closable="true" preset="card" :segmented="{content:true,footer:true}" :bordered="true" style="width:80vw;max-width:700px">
<template #header><div style="font-size:16px;font-weight:700">选择语言</div></template>
<div style="max-height:60vh;overflow-y:auto;padding:8px 0">
<NCheckboxGroup v-model:value="languages">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px 16px">
<div v-for="l in languageOptions" :key="l.code" style="margin:3px 0;display:flex;align-items:center">
<NCheckbox :value="l.code" size="small">
{{ l.code.toUpperCase() }} <span class="filter-count">({{ l.count.toLocaleString() }})</span>
</NCheckbox>
</div>
</div>
</NCheckboxGroup>
</div>
<template #footer>
<div style="display:flex;justify-content:flex-end">
<NButton size="small" type="primary" @click="showLangModal = false; goToPage(1)">确定</NButton>
</div>
</template>
</NModal>
<NModal v-model:show="showAgeModal" :mask-closable="true" preset="card" :segmented="{content:true,footer:true}" :bordered="true" style="width:80vw;max-width:740px">
<template #header><div style="font-size:16px;font-weight:700">选择年龄分组</div></template>
<div style="max-height:60vh;overflow-y:auto;padding:8px 0">
<NCheckboxGroup v-model:value="selectedAge">
<div v-for="t in specialTags.age" :key="t.key" style="margin:3px 0;display:flex;align-items:center">
<NCheckbox :value="t.key" style="font-size:13px">{{ t.label }}</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<template #footer>
<div style="display:flex;justify-content:flex-end">
<NButton size="small" type="primary" @click="showAgeModal = false; goToPage(1)">确定</NButton>
</div>
</template>
</NModal>
<!-- My Custom Filters 管理弹窗 -->
<NModal v-model:show="showFilterEditModal" :mask-closable="true" preset="card" :segmented="{content:true,footer:true}" :bordered="true" style="width:95vw;max-width:700px">
<template #header><div style="font-size:16px;font-weight:700">My Custom Filters</div></template>
<div style="max-height:60vh;overflow-y:auto;padding:8px 0">
<!-- 已有筛选器列表 -->
<div v-for="(f, i) in savedFilters" :key="f.id"
style="display:flex;align-items:center;gap:8px;padding:8px 4px;border-bottom:1px solid var(--border-color)">
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ f.name }}</div>
<div style="font-size:12px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ f.query_string }}</div>
</div>
<NButton size="tiny" quaternary @click="openEditFilter(f)">编辑</NButton>
<NButton size="tiny" quaternary @click="moveFilterUp(i)" :disabled="i===0">↑</NButton>
<NButton size="tiny" quaternary @click="moveFilterDown(i)" :disabled="i===savedFilters.length-1">↓</NButton>
<NButton size="tiny" quaternary type="error" @click="deleteSavedFilter(f.id)">删除</NButton>
</div>
<div v-if="!savedFilters.length" style="text-align:center;padding:24px 0;color:var(--text-muted);font-size:13px">
暂无自定义筛选器
</div>
</div>
<template #footer>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<NInput v-model:value="editName" placeholder="筛选器名称" size="small" style="width:150px" :maxlength="100" />
<NInput v-model:value="editQuery" placeholder="搜索查询 cancer[Title]" size="small" style="flex:1;min-width:160px" :maxlength="500" />
<NButton size="small" type="primary" @click="saveFilter()" :disabled="!editName.trim() || !editQuery.trim()">{{ editFilterId ? '更新' : '添加' }}</NButton>
</div>
</template>
</NModal>
<LiteraturePreviewDrawer
:show="showPreview"
:pmid="previewPmid"
@close="closePreview"
@go-detail="(item) => goDetail(item.pmid)"
/>
</template>
<style scoped>
/* Base layout classes */
.search-container {
display: flex;
gap: 20px;
}
.filter-panel {
width: 270px;
flex-shrink: 0;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
}
.filter-panel-scroll {
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; }
/* 发表年份分布 — 面积图 + 双向滑块 */
.histogram-chart {
height: 70px;
display: flex;
align-items: flex-end;
gap: 1px;
position: relative;
margin-bottom: 2px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1px;
}
.histogram-bar-wrap {
flex: 1;
display: flex;
align-items: flex-end;
height: 100%;
min-width: 0;
transition: opacity .15s;
}
.histogram-bar-wrap.muted {
opacity: .3;
}
.histogram-bar {
width: 100%;
min-height: 1px;
border-radius: 1px 1px 0 0;
background: linear-gradient(to top, var(--kw-pill-color) 0%, color-mix(in srgb, var(--kw-pill-color) 40%, transparent) 100%);
transition: opacity .15s;
}
html.dark .histogram-bar {
background: linear-gradient(to top, #6ab0e0 0%, color-mix(in srgb, #6ab0e0 35%, transparent) 100%);
}
.histogram-slider-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
.search-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
margin-bottom: 16px;
}
.search-input-wrap {
flex: 1;
min-width: 80px;
}
.search-bar :deep(.n-select) {
width: 105px;
flex-shrink: 0;
}
.search-bar :deep(.n-input__wrapper) {
height: 32px !important;
}
.search-bar :deep(.n-input) {
height: 32px !important;
}
.search-bar :deep(.n-base-selection) {
height: 32px !important;
}
.search-bar :deep(.n-base-selection-label) {
height: 32px !important;
}
.search-bar :deep(.n-button) {
height: 32px !important;
flex-shrink: 0;
}
@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%;
}
.results-toolbar {
flex-wrap: wrap;
gap: 8px;
}
.results-toolbar-right {
flex: 1;
justify-content: flex-end;
}
}
/* 结果工具栏 */
.results-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
}
.results-count {
color: var(--text-secondary);
font-size: 13px;
white-space: nowrap;
}
.results-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
</style>