Files
backend/backup_20260725/SearchView.vue
T
34047007@qq.com 62ca8fa6b8
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
chore: batch commit remaining changes
Includes search engine improvements, Alembic migrations,
new services (pubmed_daily_update, query_expansion),
frontend updates, and documentation sync.
2026-07-27 08:35:12 +08:00

633 lines
25 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 } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { NInput, NButton, NEmpty, NSelect, NCheckboxGroup, NCheckbox, NPagination, NIcon, NRadioGroup, NRadio } from 'naive-ui'
import { SearchOutline, FilterOutline, EyeOffOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useToast } from '../../composables/useToast'
import { useLiteraturePreview } from '../../composables/useLiteraturePreview'
import { usePagination } from '../../composables/usePagination'
import { trackAction } from '../../composables/useAnalytics'
import PageSkeleton from '../../components/common/PageSkeleton.vue'
import LiteratureCard from '../../components/literature/LiteratureCard.vue'
import LiteraturePreviewDrawer from '../../components/literature/LiteraturePreviewDrawer.vue'
import type { LiteratureItem, TagOption, SearchRequestBody } from '../../types'
const router = useRouter(); const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
// ── 搜索参数 ──
const query = ref('')
const field = ref('all')
const sort = ref('date')
const results = ref<LiteratureItem[]>([])
const loading = ref(false)
const searched = ref(false)
const savedPmids = ref<Set<number>>(new Set())
// ── 筛选参数 ──
const yearFromStr = ref(''); const yearToStr = ref('')
const datePreset = ref<string | null>(null)
const selectedTiers = ref<string[]>([])
const selectedTags = ref<string[]>([])
const pubTypes = ref<string[]>([])
const isOa = ref('') // '' = all, 'yes' = OA only, 'no' = abstract only
const language = ref('') // '' = all, or language code like 'en'
const nlmSubsets = ref<string[]>([])
const retracted = ref('') // '' = all, 'yes', 'no', 'only'
const negativeResult = ref('') // '' = all, 'yes', 'no', 'only'
const selectedSpecies = ref<string[]>([])
const selectedSex = ref<string[]>([])
const selectedAge = ref<string[]>([])
// ── 筛选选项数据 ──
const filterOptions = ref<any>(null)
const allTagsRaw = ref<TagOption[]>([])
const expandedGroups = ref<Record<string, boolean>>({})
const showFilters = ref(true)
const yearCounts = ref<{ year: number; count: number }[]>([])
const selectedYear = ref<number | null>(null) // 点击年份高亮
// ── 常用文献类型(前 8 个常驻显示) ──
const MAX_VISIBLE_PUB_TYPES = 8
const showAllPubTypes = ref(false)
// ── 常用语言(前 5 个常驻显示) ──
const MAX_VISIBLE_LANGUAGES = 5
const showAllLanguages = ref(false)
/** 收藏 / 取消收藏 */
async function handleSave(item: LiteratureItem) {
if (!auth.isAuthenticated) {
router.push('/auth/login?redirect=' + encodeURIComponent(route.fullPath))
return
}
const wasSaved = savedPmids.value.has(item.pmid)
try {
if (wasSaved) {
await api.delete(`/literature/${item.pmid}/save`)
const s = new Set(savedPmids.value)
s.delete(item.pmid)
savedPmids.value = s
toast.success('已取消收藏')
} else {
await api.post(`/literature/${item.pmid}/save`)
savedPmids.value = new Set([...savedPmids.value, item.pmid])
toast.success('已收藏')
}
} catch (e) {
toast.apiError(e, wasSaved ? '取消收藏失败' : '收藏失败')
}
}
/** 按一级分类分组的二级可选标签 */
const groupedTags = computed(() => {
const l2 = allTagsRaw.value.filter((t: TagOption) => t.level === 2 && t.is_selectable)
const l1List = allTagsRaw.value.filter((t: TagOption) => t.level === 1)
const groups: { parentId: string; parentName: string; tags: TagOption[] }[] = []
for (const l1 of l1List) {
const children = l2.filter(t => t.parent_id === String(l1.id))
if (children.length) {
groups.push({ parentId: String(l1.id), parentName: l1.name_zh, tags: children })
}
}
return groups
})
function toggleGroup(id: string) {
expandedGroups.value[id] = !expandedGroups.value[id]
}
const { showPreview, previewPmid, openPreview: setPreviewPmid, closePreview } = useLiteraturePreview()
const { page, total, goToPage } = usePagination({
fetchFn: async (p: number) => {
loading.value = true
searched.value = true
try {
if (p === 1 && query.value.trim()) {
trackAction('search', 'search', query.value.trim(), { sort: sort.value })
}
// 合并所有 tag_idsMeSH 树 + Species/Sex/Age
const allTagIds = [
...selectedTags.value,
...selectedSpecies.value,
...selectedSex.value,
...selectedAge.value,
]
const body: SearchRequestBody = {
query: query.value, field: field.value, page: p, page_size: 20, sort: sort.value,
}
if (yearFromStr.value) body.year_from = Number(yearFromStr.value)
if (yearToStr.value) body.year_to = Number(yearToStr.value)
if (datePreset.value) {
body.date_to = new Date().toISOString().slice(0, 10)
const d = new Date()
if (datePreset.value === '7d') d.setDate(d.getDate() - 7)
else if (datePreset.value === '30d') d.setDate(d.getDate() - 30)
else if (datePreset.value === '90d') d.setDate(d.getDate() - 90)
else if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
body.date_from = d.toISOString().slice(0, 10)
}
if (selectedTiers.value.length) body.journal_tiers = selectedTiers.value
if (allTagIds.length) body.tag_ids = allTagIds
if (pubTypes.value.length) body.pub_types = pubTypes.value
if (isOa.value === 'yes') body.is_oa = true
else if (isOa.value === 'no') body.is_oa = false
if (language.value) body.language = language.value
if (nlmSubsets.value.length) body.nlm_subsets = nlmSubsets.value
if (retracted.value) body.retracted = retracted.value
if (negativeResult.value) body.negative_result = negativeResult.value
const { data } = await api.post('/features/search/advanced', body)
results.value = data.items || []
total.value = data.total || 0
yearCounts.value = data.year_counts || []
} catch (e) { toast.apiError(e, '搜索失败,请重试') }
finally { loading.value = false }
},
pageSize: 20,
})
/** 从路由 query 恢复搜索参数 */
function restoreFromQuery() {
if (route.query.q) query.value = String(route.query.q)
if (route.query.field) field.value = String(route.query.field)
if (route.query.sort) sort.value = String(route.query.sort)
if (route.query.year_from) yearFromStr.value = String(route.query.year_from)
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
if (route.query.date_from) {
datePreset.value = null
}
if (route.query.tag) selectedTags.value = String(route.query.tag).split(',')
if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',')
if (route.query.pub_type) pubTypes.value = String(route.query.pub_type).split(',')
if (route.query.oa) isOa.value = String(route.query.oa)
if (route.query.lang) language.value = String(route.query.lang)
if (route.query.subset) nlmSubsets.value = String(route.query.subset).split(',')
if (route.query.retracted) retracted.value = String(route.query.retracted)
if (route.query.negative) negativeResult.value = String(route.query.negative)
if (route.query.species) selectedSpecies.value = String(route.query.species).split(',')
if (route.query.sex) selectedSex.value = String(route.query.sex).split(',')
if (route.query.age) selectedAge.value = String(route.query.age).split(',')
}
async function loadFilterOptions() {
try {
const { data } = await api.get('/features/search/filter-options')
filterOptions.value = data
} catch (e) { toast.apiError(e, '加载筛选选项失败') }
}
onMounted(async () => {
// 并行加载标签和筛选选项
await Promise.all([
api.get('/public/tags').then(({ data }) => { allTagsRaw.value = data.tags || [] }).catch(() => {}),
loadFilterOptions(),
])
// 默认只展开第一个标签分组
if (allTagsRaw.value.length) {
const firstL1 = allTagsRaw.value.find((t: TagOption) => t.level === 1)
if (firstL1) expandedGroups.value[String(firstL1.id)] = true
}
restoreFromQuery()
// 如果 query 有参数,自动触发搜索
if (route.query.q || route.query.tag) {
await goToPage(1)
}
})
function resetAllFilters() {
yearFromStr.value = ''; yearToStr.value = ''
datePreset.value = null
selectedYear.value = null
selectedTiers.value = []
selectedTags.value = []
pubTypes.value = []
isOa.value = ''
language.value = ''
nlmSubsets.value = []
retracted.value = ''
negativeResult.value = ''
selectedSpecies.value = []
selectedSex.value = []
selectedAge.value = []
query.value = ''
field.value = 'all'
sort.value = 'date'
goToPage(1)
}
/** 点击某一年筛选 */
function clickYear(year: number) {
if (selectedYear.value === year) {
// 取消选择
selectedYear.value = null
yearFromStr.value = ''
yearToStr.value = ''
} else {
selectedYear.value = year
yearFromStr.value = String(year)
yearToStr.value = String(year)
datePreset.value = null
}
goToPage(1)
}
/** 柱状图最大值 */
const maxYearCount = computed(() => {
if (!yearCounts.value.length) return 1
return Math.max(...yearCounts.value.map(y => y.count), 1)
})
function goDetail(pmid: number) {
if (auth.isAuthenticated) {
router.push(`/app/literature/${pmid}`)
} else {
router.push(`/literature/${pmid}`)
}
}
/** 筛选选项访问器 */
const pubTypeOptions = computed(() => filterOptions.value?.pub_types || [])
const languageOptions = computed(() => filterOptions.value?.languages || [])
const nlmSubsetOptions = computed(() => filterOptions.value?.nlm_subsets || [])
const specialTags = computed(() => filterOptions.value?.special_tags || {})
</script>
<template>
<div class="search-container" style="display:flex;gap:20px;max-width:1400px;margin:0 auto">
<!-- ======== 左侧高级筛选面板 ======== -->
<div v-if="showFilters" class="filter-panel">
<div class="filter-panel-scroll">
<div style="font-size:15px;font-weight:700;margin-bottom:12px;color:var(--text-primary)">筛选条件</div>
<!-- 📅 年份范围 -->
<div class="filter-section">
<div class="filter-title">📅 年份范围</div>
<div style="display:flex;gap:8px">
<NInput v-model:value="yearFromStr" placeholder="起始年" size="tiny" />
<NInput v-model:value="yearToStr" placeholder="截止年" size="tiny" />
</div>
</div>
<!-- 📆 时间范围 -->
<div class="filter-section">
<div class="filter-title">📆 时间范围</div>
<NSelect v-model:value="datePreset" :options="[
{ label: '最近一周', value: '7d' },
{ label: '最近一月', value: '30d' },
{ label: '最近三月', value: '90d' },
{ label: '最近一年', value: '1y' },
]" placeholder="选择时间范围" size="tiny" clearable />
</div>
<!-- 📊 Results by year -->
<div class="filter-section" v-if="yearCounts.length && searched">
<div class="filter-title">📊 按年份统计</div>
<div class="year-bar-chart">
<div v-for="yc in yearCounts.slice(0, 20)" :key="yc.year"
class="year-bar-row"
:class="{ active: selectedYear === yc.year }"
@click="clickYear(yc.year)">
<span class="year-bar-label">{{ yc.year }}</span>
<span class="year-bar-track">
<span class="year-bar-fill" :style="{ width: (yc.count / maxYearCount * 100) + '%' }"></span>
</span>
<span class="year-bar-count">{{ yc.count.toLocaleString() }}</span>
</div>
</div>
</div>
<!-- 📄 文献类型 (Article Type) -->
<div class="filter-section">
<div class="filter-title">📄 文献类型</div>
<NCheckboxGroup v-model:value="pubTypes">
<div v-for="(pt, i) in pubTypeOptions" :key="pt.name" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox v-if="i < MAX_VISIBLE_PUB_TYPES || showAllPubTypes" :value="pt.name" style="font-size:13px">
{{ pt.name }} <span class="filter-count">({{ pt.count.toLocaleString() }})</span>
</NCheckbox>
</div>
<div v-if="pubTypeOptions.length > MAX_VISIBLE_PUB_TYPES" style="margin-top:2px">
<span class="filter-expand-link" @click="showAllPubTypes = !showAllPubTypes">
{{ showAllPubTypes ? '收起' : `更多 (${pubTypeOptions.length - MAX_VISIBLE_PUB_TYPES})` }}
</span>
</div>
</NCheckboxGroup>
</div>
<!-- 🔓 开放获取 (Text availability) -->
<div class="filter-section">
<div class="filter-title">🔓 文本获取</div>
<NRadioGroup v-model:value="isOa" name="oaGroup">
<div style="display:flex;flex-direction:column;gap:4px">
<NRadio value="" size="small">全部</NRadio>
<NRadio value="yes" size="small">免费全文</NRadio>
<NRadio value="no" size="small">仅摘要</NRadio>
</div>
</NRadioGroup>
</div>
<!-- 🧬 Species -->
<div v-if="specialTags.species?.length" class="filter-section">
<div class="filter-title">🧬 物种</div>
<NCheckboxGroup v-model:value="selectedSpecies">
<div v-for="t in specialTags.species" :key="t.id" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<!-- 🌐 语言 -->
<div class="filter-section">
<div class="filter-title">🌐 语言</div>
<NRadioGroup v-model:value="language" name="langGroup">
<div style="display:flex;flex-direction:column;gap:4px">
<NRadio value="" size="small">全部</NRadio>
<div v-for="(l, i) in languageOptions" :key="l.code" style="margin:2px 0;display:flex;align-items:center">
<NRadio v-if="i < MAX_VISIBLE_LANGUAGES || showAllLanguages" :value="l.code" size="small">
{{ l.code.toUpperCase() }} <span class="filter-count">({{ l.count.toLocaleString() }})</span>
</NRadio>
</div>
<div v-if="languageOptions.length > MAX_VISIBLE_LANGUAGES" style="margin-top:2px">
<span class="filter-expand-link" @click="showAllLanguages = !showAllLanguages">
{{ showAllLanguages ? '收起' : `更多 (${languageOptions.length - MAX_VISIBLE_LANGUAGES})` }}
</span>
</div>
</div>
</NRadioGroup>
</div>
<!-- ♀/♂ Sex -->
<div v-if="specialTags.sex?.length" class="filter-section">
<div class="filter-title">/ 性别</div>
<NCheckboxGroup v-model:value="selectedSex">
<div v-for="t in specialTags.sex" :key="t.id" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<!-- 📰 期刊等级 -->
<div class="filter-section">
<div class="filter-title">📰 期刊等级</div>
<NCheckboxGroup v-model:value="selectedTiers">
<div v-for="t in [{v:'1',l:'四大综合'},{v:'2',l:'肿瘤顶刊'},{v:'3',l:'专科顶刊'},{v:'4',l:'其他SCI'}]" :key="t.v" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="t.v" style="font-size:13px">{{ t.l }}</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<!-- 📋 期刊子集 (Subsets) -->
<div v-if="nlmSubsetOptions.length" class="filter-section">
<div class="filter-title">📋 期刊子集</div>
<NCheckboxGroup v-model:value="nlmSubsets">
<div v-for="s in nlmSubsetOptions" :key="s.code" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="s.code" style="font-size:13px">
{{ s.label }} <span class="filter-count">({{ s.count.toLocaleString() }})</span>
</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<!-- 🏷️ 标签筛选 (MeSH) -->
<div class="filter-section">
<div class="filter-title">🏷️ MeSH 标签</div>
<NCheckboxGroup v-model:value="selectedTags">
<div v-for="g in groupedTags" :key="g.parentId" style="margin-bottom:6px">
<div style="display:flex;align-items:center;cursor:pointer;font-size:13px;font-weight:600;color:var(--text-muted);padding:2px 0;user-select:none" @click="toggleGroup(g.parentId)">
<span style="display:inline-block;width:12px;font-size:12px;transition:transform .15s" :style="{transform: expandedGroups[g.parentId] ? 'rotate(90deg)' : ''}"></span>
{{ g.parentName }}
<span style="margin-left:4px;font-size:12px;color:var(--text-muted)">({{ g.tags.length }})</span>
</div>
<template v-if="expandedGroups[g.parentId]">
<div v-for="t in g.tags" :key="t.id" style="display:flex;align-items:center;margin:1px 0;padding-left:16px">
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh }}</NCheckbox>
</div>
</template>
</div>
</NCheckboxGroup>
</div>
<!-- 👶👨👴 年龄组 -->
<div v-if="specialTags.age?.length" class="filter-section">
<div class="filter-title">👶 年龄组</div>
<NCheckboxGroup v-model:value="selectedAge">
<div v-for="t in specialTags.age" :key="t.id" style="margin:2px 0;display:flex;align-items:center">
<NCheckbox :value="t.id" style="font-size:13px">{{ t.name_zh || t.name_en }}</NCheckbox>
</div>
</NCheckboxGroup>
</div>
<!-- ⚙️ 其它 (撤稿/阴性结果) -->
<div class="filter-section">
<div class="filter-title">⚙️ 其它</div>
<div style="margin-bottom:8px">
<div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:4px">撤稿</div>
<NRadioGroup v-model:value="retracted" name="retractedGroup">
<div style="display:flex;flex-direction:column;gap:4px">
<NRadio value="" size="small">全部</NRadio>
<NRadio value="no" size="small">未撤稿</NRadio>
<NRadio value="only" size="small">仅撤稿</NRadio>
</div>
</NRadioGroup>
</div>
<div>
<div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:4px">结果类型</div>
<NRadioGroup v-model:value="negativeResult" name="negativeGroup">
<div style="display:flex;flex-direction:column;gap:4px">
<NRadio value="" size="small">全部</NRadio>
<NRadio value="no" size="small">阳性结果</NRadio>
<NRadio value="only" size="small">阴性结果</NRadio>
</div>
</NRadioGroup>
</div>
</div>
<NButton class="sky-btn" block size="small" :loading="loading" @click="goToPage(1)">
<template #icon><NIcon size="14"><FilterOutline /></NIcon></template>应用筛选
</NButton>
<div style="text-align:center;margin-top:6px">
<span style="font-size:13px;color:var(--text-muted);cursor:pointer" @click="resetAllFilters">重置所有筛选</span>
</div>
</div>
</div>
<!-- ======== 右侧搜索结果 ======== -->
<div style="flex:1;min-width:0">
<div class="search-bar" style="display:flex;margin-bottom:20px">
<div class="search-input-wrap"><NInput v-model:value="query" placeholder="搜索标题、摘要、作者、MeSH词..." size="small" @keyup.enter="goToPage(1)" style="height:34px" /></div>
<NSelect v-model:value="field" :options="[{label:'全部',value:'all'},{label:'标题',value:'title'},{label:'摘要',value:'abstract'},{label:'作者',value:'author'},{label:'机构',value:'affiliation'},{label:'期刊',value:'journal'}]" size="small" style="width:110px" @update:value="goToPage(1)" />
<NSelect v-model:value="sort" :options="[{label:'日期排序',value:'date'},{label:'被引次数',value:'cited'},{label:'相关度',value:'relevance'}]" size="small" style="width:120px" @update:value="goToPage(1)" />
<NButton class="sky-btn" size="small" :loading="loading" @click="goToPage(1)"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton>
<NButton class="sky-btn" size="small" :ghost="showFilters" @click="showFilters=!showFilters"><template #icon><NIcon size="14"><component :is="showFilters ? EyeOffOutline : FilterOutline" /></NIcon></template>{{ showFilters?'隐藏筛选':'筛选' }}</NButton>
</div>
<p v-if="searched&&total>0" style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">找到 {{ total }} 条结果</p>
<PageSkeleton :loading="loading && !results.length">
<NEmpty v-if="searched&&!loading&&!results.length" description="未找到匹配文献,尝试修改搜索条件" />
<LiteratureCard
v-for="item in results"
:key="item.pmid"
:item="item"
:searchQuery="query"
:savedPmids="savedPmids"
@detail="(i) => goDetail(i.pmid)"
@preview="(item: LiteratureItem) => setPreviewPmid(item.pmid)"
@save="handleSave"
/>
<div v-if="searched && total > 0" style="display:flex;justify-content:center;padding:20px">
<NPagination
:page="page"
:item-count="total"
:page-size="20"
@update:page="goToPage"
:simple="true"
/>
</div>
</PageSkeleton>
</div>
</div>
<LiteraturePreviewDrawer
:show="showPreview"
:pmid="previewPmid"
@close="closePreview"
@go-detail="(item) => goDetail(item.pmid)"
/>
</template>
<style scoped>
/* Base layout classes */
.search-container {
padding: 24px;
}
.filter-panel {
width: 270px;
flex-shrink: 0;
}
.filter-panel-scroll {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding-right: 4px;
}
.filter-panel-scroll::-webkit-scrollbar { width: 4px; }
.filter-panel-scroll::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
.filter-section {
margin-bottom: 14px;
}
.filter-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
color: var(--text-secondary);
}
.filter-count {
font-size: 12px;
color: var(--text-muted);
}
.filter-expand-link {
font-size: 12px;
color: var(--kw-pill-color);
cursor: pointer;
user-select: none;
}
html.dark .filter-expand-link { color: #6ab0e0; }
.filter-expand-link:hover { text-decoration: underline; }
/* Results by year 柱状图 */
.year-bar-chart {
display: flex;
flex-direction: column;
gap: 3px;
}
.year-bar-row {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: background .1s;
font-size: 12px;
}
.year-bar-row:hover { background: var(--bg-hover); }
.year-bar-row.active { background: color-mix(in srgb, var(--kw-pill-color) 12%, transparent); }
html.dark .year-bar-row.active { background: color-mix(in srgb, #6ab0e0 18%, transparent); }
.year-bar-label {
width: 36px;
text-align: right;
flex-shrink: 0;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.year-bar-row.active .year-bar-label {
color: var(--kw-pill-color);
font-weight: 600;
}
html.dark .year-bar-row.active .year-bar-label { color: #6ab0e0; }
.year-bar-track {
flex: 1;
height: 10px;
background: var(--bg-hover);
border-radius: 5px;
overflow: hidden;
min-width: 40px;
}
.year-bar-fill {
display: block;
height: 100%;
border-radius: 5px;
background: var(--kw-pill-color);
opacity: .6;
transition: width .2s, opacity .1s;
}
.year-bar-row:hover .year-bar-fill { opacity: .85; }
.year-bar-row.active .year-bar-fill { opacity: 1; }
html.dark .year-bar-fill { background: #6ab0e0; }
.year-bar-count {
width: 48px;
text-align: right;
flex-shrink: 0;
color: var(--text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.year-bar-row.active .year-bar-count {
color: var(--kw-pill-color);
font-weight: 600;
}
html.dark .year-bar-row.active .year-bar-count { color: #6ab0e0; }
.search-bar {
gap: 10px;
}
.search-input-wrap {
flex: 1;
}
@media (max-width: 768px) {
.search-container {
flex-direction: column;
padding: 12px;
}
.filter-panel {
width: 100%;
}
.filter-panel-scroll {
max-height: none;
}
.search-bar {
flex-wrap: wrap;
gap: 8px;
}
.search-input-wrap {
flex: 1 1 100%;
}
}
</style>