Files
backend/frontend/src/views/app/SearchView.vue
T
34047007@qq.com 090938fa2b fix: 第11轮深度审计修复 — P0 keyset翻页崩溃 + P1布尔符语义/语法误报等16项修复
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
2026-07-28 12:08:17 +08:00

1189 lines
51 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 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 / 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 })
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)
// 页码持久化到 URLKeyset 排序使用 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>