P0 (1项): - keyset cursor: lit.title or "__NULL__" 空字符串误判为NULL导致后续翻页全空 P1 (6项): - boolean_operator: 仅扫描depth=0的token,括号内AND/OR不再影响顶层操作符判定 - is_pubmed_syntax: 去除AND/OR/NOT检测的IGNORECASE(PubMed仅识别大写布尔符) - _relevance_query: MeSH-only查询(如breast[MAJR])补充原始回退,避免退化到日期排序 - pmid_terms: 缺少try/except int(),补充ValueError+DOI兜底 - 部分日期展开: 2024-01[DP]等YYYY-MM partial date展开为整月范围(DP/EDAT/CRDT) - 前端#N去重: resolveQuery/expandQuery dedup refs防重复引用误判为循环 P2 (9项): - cache: filter-options加入invalidate_search_cache清理 - worker: daily_ftp_update/daily_citation_update异常时finally保证缓存清理 - cursor_val: 空字符串''通过is None检查,补充not cursor_val - keyset UI: 模板条件sort==='date'改为KEYSET_SORTS.has(sort) - resetAllFilters: 补充showCustomYear=false
1189 lines
51 KiB
Vue
1189 lines
51 KiB
Vue
<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 KEYSET_SORTS = new Set(['date', 'cited', 'title', 'journal', 'first_author'])
|
||
|
||
// ── 搜索参数 ──
|
||
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 urlDateFrom = ref(''); const urlDateTo = ref('') // 来自 URL 的完整日期(保留精度)
|
||
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[]>([])
|
||
const booleanOp = ref('and') // "and" | "or"
|
||
const exactPhrase = ref(false)
|
||
|
||
// ── 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 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 游标分页(所有排序模式通用,跳过 COUNT 和 OFFSET) ──
|
||
const keysetPage = ref(1)
|
||
const keysetCursors = ref<Array<{cursor_val: 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
|
||
urlDateFrom.value = ''; urlDateTo.value = '' // P6: clear stale URL dates
|
||
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,
|
||
boolean: booleanOp.value,
|
||
}
|
||
if (exactPhrase.value) body.exact_phrase = true
|
||
if (field.value !== 'all') body.field = field.value
|
||
// P3-6: precision_mode 不再发送(后端已忽略)
|
||
// ── Keyset 游标分页(所有排序模式通用,跳过 COUNT + OFFSET) ──
|
||
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_val = entry.cursor_val
|
||
body.cursor_id = entry.cursor_id
|
||
}
|
||
// cursor 不存在时保留 body.page,回退到 offset 分页
|
||
}
|
||
// 年份 / 日期(datePreset 与 year_* 互斥)
|
||
const yf = Number(yearFromStr.value)
|
||
const yt = Number(yearToStr.value)
|
||
if (datePreset.value === 'custom') {
|
||
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
|
||
} else if (!datePreset.value && (urlDateFrom.value || urlDateTo.value)) {
|
||
// 来自 URL 的完整日期(保留月日精度)
|
||
if (urlDateFrom.value) body.date_from = urlDateFrom.value
|
||
if (urlDateTo.value) body.date_to = urlDateTo.value
|
||
} else {
|
||
if (!datePreset.value && yearFromStr.value && !isNaN(yf) && yf >= 1900 && yf <= 2100) body.year_from = yf
|
||
if (!datePreset.value && yearToStr.value && !isNaN(yt) && yt >= 1900 && yt <= 2100) body.year_to = yt
|
||
}
|
||
if (datePreset.value && datePreset.value !== 'custom') {
|
||
const now = new Date()
|
||
body.date_to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString().slice(0, 10)
|
||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||
if (datePreset.value === '1y') d.setUTCFullYear(d.getUTCFullYear() - 1)
|
||
else if (datePreset.value === '5y') d.setUTCFullYear(d.getUTCFullYear() - 5)
|
||
else if (datePreset.value === '10y') d.setUTCFullYear(d.getUTCFullYear() - 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 / Age(mesh_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 })
|
||
if (gen !== searchGeneration.value) return // 丢弃老旧请求
|
||
results.value = data.items || []
|
||
// 游标分页:首页走 COUNT 存 total,后续页沿用;cursor 和数据一起返回
|
||
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 && data.has_more && data.cursor_val) {
|
||
keysetCursors.value[p + 1] = {
|
||
cursor_val: data.cursor_val,
|
||
cursor_id: data.cursor_id || items[items.length - 1].id,
|
||
}
|
||
} else {
|
||
delete keysetCursors.value[p + 1]
|
||
}
|
||
yearCounts.value = data.year_counts || []
|
||
} catch (e: any) {
|
||
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
|
||
toast.apiError(e, '搜索失败,请重试')
|
||
}
|
||
finally {
|
||
syncSearchToUrl() // P6: sync URL even on error (avoid URL/state desync)
|
||
if (gen === searchGeneration.value) loading.value = false
|
||
}
|
||
},
|
||
pageSize: pageSize.value,
|
||
})
|
||
|
||
/** 从路由 query 恢复搜索参数 */
|
||
function restoreFromQuery() {
|
||
if (route.query.q) query.value = String(route.query.q)
|
||
const VALID_FIELDS = new Set(['all', 'title', 'abstract', 'author', 'affiliation', 'journal'])
|
||
const VALID_SORTS = new Set(['date', 'cited', 'best_match', 'relevance', 'first_author', 'journal', 'title'])
|
||
if (route.query.field && VALID_FIELDS.has(String(route.query.field))) field.value = String(route.query.field)
|
||
if (route.query.sort && VALID_SORTS.has(String(route.query.sort))) sort.value = String(route.query.sort)
|
||
if (route.query.date_preset && ['1y','5y','10y','custom'].includes(String(route.query.date_preset))) {
|
||
datePreset.value = String(route.query.date_preset)
|
||
yearFromStr.value = ''
|
||
yearToStr.value = ''
|
||
} else if (route.query.date_from || route.query.date_to) {
|
||
datePreset.value = null
|
||
if (route.query.date_from) {
|
||
const df = String(route.query.date_from)
|
||
urlDateFrom.value = df // 保留完整日期避免精度丢失
|
||
if (df.length >= 4) yearFromStr.value = df.slice(0, 4)
|
||
}
|
||
if (route.query.date_to) {
|
||
const dt = String(route.query.date_to)
|
||
urlDateTo.value = dt
|
||
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
|
||
}
|
||
} else {
|
||
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.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.boolean === 'or') booleanOp.value = 'or'
|
||
if (route.query.exact_phrase === 'true') exactPhrase.value = true
|
||
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.p) restoredPage.value = parseInt(String(route.query.p)) || 1
|
||
if (route.query.page_size) {
|
||
const ps = parseInt(String(route.query.page_size))
|
||
if (ps >= 10 && ps <= 100) pageSize.value = ps
|
||
}
|
||
// keyset 游标不能从 URL 恢复 → page>1 回退首页(仅键集排序模式)
|
||
if (KEYSET_SORTS.has(sort.value) && restoredPage.value > 1) restoredPage.value = 1
|
||
}
|
||
|
||
// 选择预设(1y/5y/10y)时清除自定义年份并自动搜索
|
||
watch(datePreset, (val) => {
|
||
if (val && val !== 'custom') {
|
||
yearFromStr.value = ''
|
||
yearToStr.value = ''
|
||
urlDateFrom.value = ''; urlDateTo.value = '' // 清除 URL 日期
|
||
if (searched.value) goToPage(1)
|
||
}
|
||
if (val === 'custom') {
|
||
urlDateFrom.value = ''; urlDateTo.value = '' // 自定义年份覆盖 URL 日期
|
||
}
|
||
})
|
||
|
||
// P2-10: 弹窗关闭时(mask-closable 或确定按钮)自动搜索
|
||
watch(showPubTypeModal, (val) => { if (!val && searched.value) goToPage(1) })
|
||
watch(showLangModal, (val) => { if (!val && searched.value) goToPage(1) })
|
||
watch(showAgeModal, (val) => { if (!val && 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 (datePreset.value && datePreset.value !== 'custom') {
|
||
q.date_preset = datePreset.value
|
||
// 同时保存计算出的 date_from/date_to,使分享的 URL 能直接恢复
|
||
const now = new Date()
|
||
q.date_to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString().slice(0, 10)
|
||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||
if (datePreset.value === '1y') d.setUTCFullYear(d.getUTCFullYear() - 1)
|
||
else if (datePreset.value === '5y') d.setUTCFullYear(d.getUTCFullYear() - 5)
|
||
else if (datePreset.value === '10y') d.setUTCFullYear(d.getUTCFullYear() - 10)
|
||
q.date_from = d.toISOString().slice(0, 10)
|
||
} else {
|
||
if (datePreset.value === 'custom') q.date_preset = 'custom'
|
||
if (urlDateFrom.value) q.date_from = urlDateFrom.value
|
||
else if (yearFromStr.value) q.year_from = yearFromStr.value
|
||
if (urlDateTo.value) q.date_to = urlDateTo.value
|
||
else if (yearToStr.value) q.year_to = yearToStr.value
|
||
}
|
||
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 (booleanOp.value !== 'and') q.boolean = booleanOp.value
|
||
if (exactPhrase.value) q.exact_phrase = 'true'
|
||
if (pageSize.value !== 20) q.page_size = String(pageSize.value)
|
||
// 页码持久化到 URL(Keyset 排序使用 keyset 页码,offset 排序使用 offset 页码)
|
||
const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value
|
||
if (currentPage > 1) q.p = String(currentPage)
|
||
router.replace({ query: q }).catch(() => {})
|
||
}
|
||
|
||
function resetAllFilters() {
|
||
yearFromStr.value = ''; yearToStr.value = ''
|
||
urlDateFrom.value = ''; urlDateTo.value = '' // P6: clear stale URL dates
|
||
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
|
||
query.value = ''
|
||
field.value = 'all'
|
||
sort.value = 'date'
|
||
booleanOp.value = 'and'
|
||
exactPhrase.value = false
|
||
showCustomYear.value = false
|
||
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 aria-label="搜索文献" @keyup.enter="goToPage(1)" /></div>
|
||
<NSelect v-model:value="booleanOp" :options="[{label:'AND',value:'and'},{label:'OR',value:'or'}]" size="small" style="width:75px;flex-shrink:0" />
|
||
<NCheckbox v-model:checked="exactPhrase" size="small" style="flex-shrink:0;font-size:13px;white-space:nowrap">精确短语</NCheckbox>
|
||
<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:90px;flex-shrink:0" />
|
||
<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:'Most Cited',value:'cited'},
|
||
{label:'Relevance',value:'relevance'},
|
||
{label:'First author',value:'first_author'},
|
||
{label:'Journal',value:'journal'},
|
||
{label:'Title',value:'title'},
|
||
]" 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 && KEYSET_SORTS.has(sort) && 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>
|