chore: batch commit remaining changes
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

Includes search engine improvements, Alembic migrations,
new services (pubmed_daily_update, query_expansion),
frontend updates, and documentation sync.
This commit is contained in:
34047007@qq.com
2026-07-27 08:35:12 +08:00
parent 35b0a5c565
commit 62ca8fa6b8
82 changed files with 314248 additions and 1519 deletions
+752
View File
@@ -0,0 +1,752 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NButton, NEmpty, NIcon } from 'naive-ui'
import { ChevronDownOutline, ChevronUpOutline, CloseCircleOutline, HelpCircleOutline, OptionsOutline, RefreshOutline, SearchOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { useToast } from '../../composables/useToast'
import { trackAction } from '../../composables/useAnalytics'
import { useLiteraturePreview } from '../../composables/useLiteraturePreview'
import PageSkeleton from '../../components/common/PageSkeleton.vue'
import type { LiteratureItem, TagOption } from '../../types'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
// ── 搜索状态(统一数据源) ──
const searchParams = ref({
query: '',
field: 'all',
tag_ids: [] as string[],
date_from: null as string | null,
date_to: null as string | null,
retracted: '',
negative_result: '',
precision_mode: 'majr',
page_size: 20,
sort: 'date',
})
const feedItems = ref<LiteratureItem[]>([])
const loading = ref(false)
const searched = ref(false)
const searchTotal = ref(0)
// keyset 游标(用于"加载更多"page 被忽略)
const cursorDate = ref<string | null>(null)
const cursorId = ref<string | null>(null)
const hasMoreItems = ref(false)
// ── 公开统计 ──
const platformStats = ref({ literature_total: 0, journal_total: 0, daily_avg_30d: 0 })
const statsLoading = ref(true)
// ── 热搜 ──
const hotArticles = ref<any[]>([])
const hotPage = ref(1)
const hotPageSize = 5
const hotTotal = ref(0)
const hotMaxPage = 6 // 最多3页
const hotError = ref(false)
const hotAvailablePages = computed(() => Math.min(hotMaxPage, Math.ceil(hotTotal.value / hotPageSize)))
async function loadHotArticles() {
try {
const { data } = await api.get('/public/hot-articles', { params: { page: hotPage.value, page_size: hotPageSize } })
hotArticles.value = data.items || []
hotTotal.value = data.total || 0
hotError.value = false
} catch (e) { hotError.value = true }
}
function refreshHot() {
const max = Math.min(hotMaxPage, Math.ceil(hotTotal.value / hotPageSize) || 1)
hotPage.value = hotPage.value >= max ? 1 : hotPage.value + 1
loadHotArticles()
}
async function loadStats() {
try {
const { data } = await api.get('/public/stats')
platformStats.value = data
} catch (e) { /* stats are non-critical */ }
finally { statsLoading.value = false }
}
// ── 热门标签 ──
const allTags = ref<TagOption[]>([])
const selectedTagIds = ref<string[]>([])
const showAllTags = ref(false)
const isMobile = ref(window.innerWidth < 768)
// ── 高级搜索展开 ──
const showAdvanced = ref(false)
function onDocumentMouseDown(e: MouseEvent) {
if (!showAdvanced.value) return
const el = e.target as HTMLElement
if (!el?.closest('.adv-filter-panel') && !el?.closest('.adv-icon-btn')) {
showAdvanced.value = false
}
}
// ── 本地搜索关键词(行内快速搜索) ──
const localQuery = ref('')
// ── 预览抽屉 ──
const { showPreview, previewPmid, openPreview: setPreviewPmid, closePreview } = useLiteraturePreview()
// ── 收藏 ──
const saving = ref<Set<number>>(new Set())
const savedPmids = ref<Set<number>>(new Set())
// ── "上次看到这里" 分隔线(用日期字符串比较,不含时间精度) ──
const lastVisitDateStr = localStorage.getItem('home_last_visit_date')
const dividerIndex = computed(() => {
if (!lastVisitDateStr || !feedItems.value.length) return -1
const idx = feedItems.value.findIndex((item: LiteratureItem) => {
const d = (item.article_date || item.pub_date)?.slice(0, 10)
return d && d <= lastVisitDateStr
})
return idx > 0 ? idx : -1
})
// ── 首页专用加载(不走高级搜索) ──
async function loadHomepageFeed() {
try {
const { data } = await api.get('/public/homepage-feed')
feedItems.value = data.items || []
hasMoreItems.value = data.has_more ?? false
const items = data.items || []
if (items.length > 0) {
const last = items[items.length - 1]
cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null
cursorId.value = last.id || null
}
searched.value = true
} catch (e) { toast.apiError(e, '加载文献失败') }
}
// ── 数据加载核心 ──
async function fetchData(resetPage = true) {
if (resetPage) {
cursorDate.value = null
cursorId.value = null
}
loading.value = true
try {
const body: Record<string, any> = { page_size: searchParams.value.page_size, sort: searchParams.value.sort }
if (searchParams.value.query) body.query = searchParams.value.query
if (searchParams.value.field !== 'all') body.field = searchParams.value.field
if (searchParams.value.tag_ids.length) body.tag_ids = searchParams.value.tag_ids
if (searchParams.value.date_from) body.date_from = searchParams.value.date_from
if (searchParams.value.date_to) body.date_to = searchParams.value.date_to
if (searchParams.value.retracted) body.retracted = searchParams.value.retracted
if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result
if (searchParams.value.precision_mode) body.precision_mode = searchParams.value.precision_mode
// keyset 游标
if (cursorDate.value && cursorId.value) {
body.cursor_date = cursorDate.value
body.cursor_id = cursorId.value
}
const { data } = await api.post('/features/search/advanced', body)
if (resetPage) {
feedItems.value = data.items || []
searchTotal.value = data.total ?? 0
} else {
feedItems.value.push(...(data.items || []))
}
hasMoreItems.value = data.has_more ?? false
// 记录游标(取最后一条)
const items = data.items || []
if (items.length > 0) {
const last = items[items.length - 1]
cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null
cursorId.value = last.id || null
}
searched.value = true
} catch (e) { toast.apiError(e, '搜索文献失败,请重试') }
finally { loading.value = false }
}
// ── 标签筛选(多选) ──
function toggleTag(tagId: string | number) {
const id = String(tagId)
const idx = selectedTagIds.value.indexOf(id)
if (idx >= 0) {
selectedTagIds.value.splice(idx, 1)
} else {
selectedTagIds.value.push(id)
trackAction('click_tag', 'tag', id)
}
searchParams.value.tag_ids = [...selectedTagIds.value]
fetchData()
}
// ── 癌种标签(过滤 tag_category === 'cancer',二级标签,有中文名,按文献数倒序) ──
const cancerTags = computed(() => allTags.value
.filter((t: TagOption) => t.tag_category === 'cancer' && t.level === 2 && t.name_zh)
.sort((a, b) => ((b.article_count as number) || 0) - ((a.article_count as number) || 0))
)
// 超过 7 个标签时显示"更多"按钮(仅移动端折叠)
const hasMoreTags = computed(() => cancerTags.value.length > 7)
// 折叠时只显示第一行标签,展开时显示全部
const visibleTags = computed(() => {
if (showAllTags.value) return cancerTags.value
return cancerTags.value.slice(0, 7)
})
// ── 行内搜索 ──
function doLocalSearch() {
showAdvanced.value = false
const q = localQuery.value.trim()
const query: Record<string, string> = {}
if (q) query.q = q
router.push({ name: 'public-search', query })
}
function clearSearch() {
localQuery.value = ''
searchParams.value = {
query: '', field: 'all', tag_ids: [], date_from: null, date_to: null,
retracted: '', negative_result: '', precision_mode: 'majr',
page_size: 20, sort: 'date',
}
selectedTagIds.value = []
showAdvanced.value = false
}
// ── 高级搜索 ──
function handleAdvancedSearch(params: {
query: string
field: string
date_from: string | null
date_to: string | null
journal_tiers: string[]
tag_ids: string[]
retracted: string
negative_result: string
precision_mode: string
sort: string
}) {
showAdvanced.value = false
const q: Record<string, string> = {}
if (params.query) q.q = params.query
if (params.field !== 'all') q.field = params.field
if (params.date_from) q.date_from = params.date_from
if (params.date_to) q.date_to = params.date_to
if (params.journal_tiers.length) q.tier = params.journal_tiers.join(',')
if (params.tag_ids.length) q.tag = params.tag_ids.join(',')
if (params.retracted) q.retracted = params.retracted
if (params.negative_result) q.negative = params.negative_result
if (params.sort !== 'date') q.sort = params.sort
router.push({ name: 'public-search', query: q })
}
// ── 收藏 ──
async function saveLiterature(item: LiteratureItem) {
if (!auth.isAuthenticated) {
router.push('/auth/login')
return
}
if (saving.value.has(item.pmid) || savedPmids.value.has(item.pmid)) return
saving.value = new Set(saving.value).add(item.pmid)
try {
await api.post(`/literature/${item.pmid}/save`)
savedPmids.value = new Set(savedPmids.value).add(item.pmid)
toast.success('已收藏')
} catch (e) { toast.apiError(e, '收藏文献失败,请重试') }
finally {
const s = new Set(saving.value)
s.delete(item.pmid)
saving.value = s
}
}
// ── 预览 ──
function openPreview(item: LiteratureItem) {
if (item.pmid) setPreviewPmid(item.pmid)
}
function goDetail(item: LiteratureItem) {
closePreview()
const q = searchParams.value.query
router.push(q ? `/literature/${item.pmid}?q=${encodeURIComponent(q)}` : `/literature/${item.pmid}`)
}
// ── 收藏持久化 ──
async function loadSavedPmids() {
if (!auth.isAuthenticated) return
try {
const { data } = await api.get('/literature/saved')
savedPmids.value = new Set((data.items || []).map((i: any) => i.pmid))
} catch (e) { toast.apiError(e, '加载收藏状态失败') }
}
// ── 加载更多(追加下一页) ──
const loadingMore = ref(false)
const hasMore = computed(() => hasMoreItems.value)
// ── 回到顶部 ──
const showScrollTop = ref(false)
function onScroll() {
showScrollTop.value = window.scrollY > window.innerHeight
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function onResize() {
isMobile.value = window.innerWidth < 768
}
async function loadMore() {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
try {
const body: Record<string, any> = { page_size: searchParams.value.page_size, sort: 'date' }
if (searchParams.value.query) body.query = searchParams.value.query
if (searchParams.value.field !== 'all') body.field = searchParams.value.field
if (searchParams.value.tag_ids.length) body.tag_ids = searchParams.value.tag_ids
if (searchParams.value.date_from) body.date_from = searchParams.value.date_from
if (searchParams.value.date_to) body.date_to = searchParams.value.date_to
if (searchParams.value.retracted) body.retracted = searchParams.value.retracted
if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result
if (searchParams.value.precision_mode) body.precision_mode = searchParams.value.precision_mode
if (cursorDate.value && cursorId.value) {
body.cursor_date = cursorDate.value
body.cursor_id = cursorId.value
}
const { data } = await api.post('/features/search/advanced', body)
feedItems.value.push(...(data.items || []))
hasMoreItems.value = data.has_more ?? false
// 更新游标
const items = data.items || []
if (items.length > 0) {
const last = items[items.length - 1]
cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null
cursorId.value = last.id || null
}
} catch (e) { toast.apiError(e, '加载更多失败,请重试') }
finally { loadingMore.value = false }
}
// ── 从 URL 恢复状态 ──
function restoreFromUrl() {
const tag = route.query.tag as string
const date = route.query.date as string
const q = route.query.q as string
if (tag) {
const ids = tag.split(',')
selectedTagIds.value = ids
searchParams.value.tag_ids = ids
}
if (date) {
searchParams.value.date_from = date
searchParams.value.date_to = date
}
if (q) {
searchParams.value.query = q
localQuery.value = q
}
}
// ── 同步状态到 URL ──
const _mounted = ref(false)
const searchKey = computed(() =>
JSON.stringify([searchParams.value.query, searchParams.value.tag_ids, searchParams.value.date_from, searchParams.value.date_to, searchParams.value.sort])
)
watch(searchKey, () => {
if (!_mounted.value) return
const query: Record<string, string> = {}
if (searchParams.value.tag_ids.length) query.tag = searchParams.value.tag_ids.join(',')
if (searchParams.value.date_from) query.date = searchParams.value.date_from
if (searchParams.value.query) query.q = searchParams.value.query
router.replace({ query })
})
// ── 初始化 ──
onMounted(async () => {
restoreFromUrl()
loadStats()
loadHotArticles()
// URL 有搜索参数时不加载首页 Feed(后续 fetchData 会覆盖)
const hasSearchParams = !!(searchParams.value.query || searchParams.value.tag_ids.length ||
searchParams.value.date_from || searchParams.value.date_to)
const [tagsResp] = await Promise.all([
api.get('/public/tags').catch(() => ({ data: { tags: [] } })),
hasSearchParams ? Promise.resolve() : loadHomepageFeed(),
])
allTags.value = (tagsResp?.data?.tags || [])
if (hasSearchParams) {
await fetchData()
}
await loadSavedPmids()
// 记录本次访问日期(用于"上次看到这里"分隔线,只用日期避免时间精度问题)
localStorage.setItem('home_last_visit_date', new Date().toLocaleDateString('sv'))
_mounted.value = true
window.addEventListener('scroll', onScroll)
window.addEventListener('resize', onResize)
document.addEventListener('mousedown', onDocumentMouseDown)
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
document.removeEventListener('mousedown', onDocumentMouseDown)
})
</script>
<template>
<div class="home">
<h1 class="visually-hidden">OncoLit 肿瘤科研文献速递每日更新的肿瘤学文献平台</h1>
<!-- ====== 内容网格左侧搜索 + 标签 + 文献+ 右侧热榜 ====== -->
<div class="content-grid">
<div class="content-grid-main">
<!-- 搜索工具栏 -->
<section class="section section-toolbar">
<div class="search-row">
<div class="search-row-left">
<div class="search-input-group">
<div class="search-input-top">
<input class="search-input" v-model="localQuery" placeholder="搜索标题、摘要、PMID、DOI..." @keyup.enter="doLocalSearch" />
<NButton v-if="localQuery" text size="tiny" @click="clearSearch" class="clear-search-btn" title="清除搜索">
<template #icon><NIcon size="16"><CloseCircleOutline /></NIcon></template>
</NButton>
</div>
<div class="search-input-actions">
<NButton text size="tiny" @click="router.push('/help?tab=syntax')" class="help-icon-btn" title="搜索帮助">
<template #icon><NIcon size="19"><HelpCircleOutline /></NIcon></template>
</NButton>
<NButton text size="tiny" @click="showAdvanced = !showAdvanced" :title="showAdvanced?'收起筛选':'高级搜索'" class="adv-icon-btn">
<template #icon><NIcon size="18"><OptionsOutline /></NIcon></template>
</NButton>
<NButton size="small" type="primary" @click="doLocalSearch" class="sky-btn search-submit-btn">
<template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索
</NButton>
</div>
</div>
</div>
</div>
<AdvancedSearchPanel
:show="showAdvanced"
:loading="loading"
:tags="allTags"
@search="handleAdvancedSearch"
/>
</section>
<!-- 热门标签快速筛选 -->
<section class="section" v-if="cancerTags.length">
<div class="tag-bar">
<span class="tag-label">热门</span>
<span v-for="t in visibleTags" :key="t.id" class="tag-pill"
:class="{ active: selectedTagIds.includes(String(t.id)) }" @click="toggleTag(t.id)">
{{ t.name_zh || t.name_en }}<span v-if="selectedTagIds.includes(String(t.id))" class="tag-x"></span>
</span>
<span v-if="hasMoreTags && !showAllTags" class="tag-expand-btn" @click="showAllTags = true">更多 <NIcon size="13" style="vertical-align:-2px"><ChevronDownOutline /></NIcon></span>
<span v-if="showAllTags" class="tag-expand-btn" @click="showAllTags = false">收起 <NIcon size="13" style="vertical-align:-2px"><ChevronUpOutline /></NIcon></span>
</div>
</section>
<!-- 文献列表 -->
<section class="section section-list">
<PageSkeleton :loading="loading && !feedItems.length">
<NEmpty v-if="!loading && !feedItems.length" description="暂无文献" />
<div v-if="!loading && !feedItems.length && searched && searchParams.query" class="search-result-count" style="text-align:center;margin-top:8px"> 0 条结果</div>
<template v-if="feedItems.length">
<template v-for="(item, idx) in feedItems" :key="item.id || item.pmid">
<div v-if="idx === dividerIndex" class="read-divider">
<span class="read-divider-line"></span>
<span class="read-divider-text"> 上次看到这里 </span>
<span class="read-divider-line"></span>
</div>
<LiteratureCard
:item="item"
:showSave="auth.isAuthenticated"
:savedPmids="savedPmids"
:searchQuery="searchParams.query"
@detail="goDetail"
@preview="openPreview"
@save="saveLiterature"
/>
</template>
<div v-if="searched && searchParams.query" class="search-result-count"> {{ searchTotal }} 条结果</div>
<div v-if="hasMore" class="load-more">
<NButton class="sky-btn" :loading="loadingMore" @click="loadMore" size="small"><template #icon><NIcon size="14"><ChevronDownOutline /></NIcon></template>展示更多</NButton>
</div>
</template>
</PageSkeleton>
</section>
</div>
<div class="content-grid-side">
<section v-if="hotError" class="section-hot-side" style="padding-top:12px">
<div class="hot-header"><span>🔥 热搜</span></div>
<div style="text-align:center;padding:20px 0;font-size:13px;color:var(--text-muted)">加载失败</div>
</section>
<section v-else-if="hotArticles.length" class="section-hot-side">
<div class="hot-header">
<span>🔥 热搜</span>
<div class="hot-header-right">
<span class="hot-page-dots">
<span v-for="p in hotAvailablePages" :key="p" class="hot-page-dot" :class="{ active: hotPage === p }"></span>
</span>
<NButton text size="tiny" style="font-size:12px;color:var(--text-muted)" @click="refreshHot">
<template #icon><NIcon size="12"><RefreshOutline /></NIcon></template>换一批
</NButton>
</div>
</div>
<div class="hot-list">
<a v-for="(art, i) in hotArticles" :key="art.id" class="hot-item" :href="`/literature/${art.pmid}`">
<span class="hot-rank" :class="{ 'hot-rank-top': (hotPage-1)*hotPageSize + i + 1 <= 3 }">{{ (hotPage-1)*hotPageSize + i + 1 }}</span>
<div class="hot-content">
<span class="hot-title" :title="art.title">{{ art.title }}</span>
<span class="hot-meta">{{ art.journal }} · {{ art.cited_by_count ?? 0 }} 被引</span>
</div>
</a>
</div>
</section>
<section v-if="!statsLoading" class="sidebar-stats-section">
<div class="stats-header">
<span>📚 OncoLit 肿瘤科研文献</span>
<span class="stats-header-right">每日更新</span>
</div>
<div class="stats-subtitle">PubMed 全量文献数据 +</div>
<template v-if="platformStats.literature_total > 0">
<div class="sidebar-stats">
<div class="sidebar-stat-item">
<span class="sidebar-stat-num">{{ platformStats.literature_total.toLocaleString() }}</span>
<span class="sidebar-stat-label">文献总量</span>
</div>
<div class="sidebar-stat-item">
<span class="sidebar-stat-num">{{ platformStats.journal_total }}</span>
<span class="sidebar-stat-label">收录期刊</span>
</div>
<div class="sidebar-stat-item">
<span class="sidebar-stat-num">{{ platformStats.daily_avg_30d.toFixed(1) }}</span>
<span class="sidebar-stat-label">日均新增</span>
</div>
</div>
</template>
<div v-else style="text-align:center;padding:12px;font-size:13px;color:var(--text-muted)">统计信息加载中...</div>
<div class="sidebar-feedback" @click="router.push('/help?tab=feedback')">
<svg class="feedback-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="9" y1="10" x2="15" y2="10"/></svg>
<span>意见反馈</span>
<svg class="feedback-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</section>
</div>
</div>
<!-- ====== 注册引导 CTA未登录 ====== -->
<!-- <section v-if="!auth.isAuthenticated && feedItems.length > 3" class="section section-cta">
<div class="cta-banner">
<div class="cta-text">
<div class="cta-title">🔒 注册后获得完整功能</div>
<div class="cta-desc">收藏文献 · 设置关注领域 · 每日精准推送 · 团队共享</div>
</div>
<NButton class="sky-btn" type="primary" size="large" @click="router.push('/auth/register')"><template #icon><NIcon size="14"><PersonAddOutline /></NIcon></template>免费注册</NButton>
</div>
</section> -->
<!-- ====== 文献预览抽屉 ====== -->
<LiteraturePreviewDrawer
:show="showPreview"
:pmid="previewPmid"
@close="closePreview"
@goDetail="goDetail"
/>
<!-- 回到顶部 -->
<button v-if="showScrollTop" class="scroll-top" @click="scrollToTop"></button>
</div>
</template>
<style scoped>
.home { max-width: 1100px; margin: 0 auto; padding: 0 24px; color: var(--text-primary); }
.section { padding: 3px 0; }
.section-list { padding-bottom: 16px; }
.section-cta { padding: 12px 0 24px; }
/* 内容网格:文献列表 + 热榜侧栏 */
.content-grid { display: flex; gap: 24px; align-items: flex-start; }
.content-grid-main { flex: 3; min-width: 0; }
.content-grid-side { flex: 1; min-width: 260px; max-width: 300px; }
.section-hot-side { padding-top: 12px; }
.section-hot-side .hot-header { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
.section-hot-side .hot-header-right { display: flex; align-items: center; gap: 6px; padding-right: 3px; }
.hot-page-dots { display: flex; align-items: center; gap: 4px; }
.hot-page-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border-color); transition: background .15s; }
.hot-page-dot.active { background: var(--kw-pill-color); }
html.dark .hot-page-dot.active { background: #6ab0e0; }
.hot-rank-top { color: #d03050 !important; font-weight: 800 !important; }
.section-hot-side .hot-list { display: flex; flex-direction: column; gap: 3px; }
.section-hot-side .hot-item { display: flex; align-items: flex-start; gap: 3px; padding: 6px 4px; border-radius: 6px; text-decoration: none; transition: background .12s; }
.section-hot-side .hot-item:hover { background: var(--bg-hover); }
.section-hot-side .hot-rank { flex-shrink: 0; width: 20px; text-align: center; font-size: 13px; font-weight: 700; color: #d03050; line-height: 1.6; }
.section-hot-side .hot-content { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.section-hot-side .hot-title { font-size: 16px; color: var(--text-primary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.section-hot-side .hot-title:hover { color: var(--kw-pill-color); }
html.dark .section-hot-side .hot-title:hover { color: #6ab0e0; }
.section-hot-side .hot-meta { font-size: 14px; color: var(--text-muted); }
/* 热门标签 */
.tag-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0; align-items: center; }
.tag-label { font-size: 14px; font-weight: 600; color: var(--text-primary); white-space: nowrap; margin-right: 4px; flex-shrink: 0; }
.tag-pill {
white-space: nowrap; padding: 5px 14px; border-radius: 20px; font-size: 14px; cursor: pointer;
border: 1px solid var(--border-color); color: var(--text-secondary); background: var(--bg-card); transition: all .12s; user-select: none;
display: inline-flex; align-items: center; gap: 3px;
}
.tag-pill:hover { border-color: var(--kw-pill-color); color: var(--kw-pill-color); }
html.dark .tag-pill:hover { border-color: #6ab0e0; color: #6ab0e0; }
.tag-pill.active { background: var(--kw-pill-color); color: #fff; border-color: var(--kw-pill-color); }
html.dark .tag-pill.active { background: #2a5a8a; color: #fff; border-color: #2a5a8a; }
.tag-x { font-size: 11px; margin-left: 1px; opacity: .8; }
.tag-expand-btn {
white-space: nowrap; font-size: 13px; cursor: pointer; user-select: none;
padding: 3px 10px; border-radius: 20px; border: 1px solid #b3d8ff;
background: #e6f4ff; color: var(--text-primary);
align-self: center; line-height: 1.5;
}
.tag-expand-btn:hover { background: #d0ebff; }
html.dark .tag-expand-btn { background: #1a3a5c; border-color: #2a5a8a; color: #6ab0e0; }
html.dark .tag-expand-btn:hover { background: #1e4a6c; }
/* "上次看到这里" 分隔线 */
.read-divider {
display: flex; align-items: center; gap: 12px;
padding: 24px 0 12px; user-select: none;
}
.read-divider-line { flex: 1; height: 1px; background: linear-gradient(to right, transparent, var(--border-color) 30%, var(--border-color) 70%, transparent); }
.read-divider-text { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
/* 搜索工具栏 — 小圆角左对齐 */
.section-toolbar { padding: 15px 0 2px; }
.search-row {
display: flex; justify-content: flex-start; align-items: center;
gap: 6px; margin-bottom: 2px;
}
.search-row-left { display: flex; flex: 1; }
.search-input-group {
display: flex; flex-direction: column; flex: 1; min-width: 120px;
border: 2px solid var(--border-color); border-radius: 6px;
background: var(--bg-card); padding: 6px 12px 6px;
transition: border-color .15s;
}
.search-input-group:focus-within { border-color: var(--kw-pill-color); }
html.dark .search-input-group:focus-within { border-color: #6ab0e0; }
.search-input-top { display: flex; align-items: center; gap: 4px; flex: 1; }
.search-input {
border: none; outline: none; padding: 8px 0 4px; font-size: 16px;
flex: 1; min-width: 0; background: transparent;
}
.search-input::placeholder { font-size: 14px; color: var(--text-muted); }
html.dark .search-input { color: #fff; }
html.dark .search-input::placeholder { color: #bbb; }
.search-input-actions { display: flex; align-items: center; justify-content: flex-end; gap: 20px; }
.adv-icon-btn { color: var(--kw-pill-color); margin-left: -3px; }
html.dark .adv-icon-btn { color: #6ab0e0; }
.help-icon-btn { color: var(--kw-pill-color); }
html.dark .help-icon-btn { color: #6ab0e0; }
.clear-search-btn { color: var(--text-muted); flex-shrink: 0; }
.search-submit-btn { height: 28px; border-radius: 6px; }
/* 加载更多 */
/* 加载更多 */
.load-more { text-align: center; padding: 20px; }
.search-result-count { padding: 6px 0 2px; font-size: 12px; color: var(--text-muted); text-align: left; }
/* CTA */
.cta-banner {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 20px 24px; background: var(--bg-hover); border-radius: 10px;
}
.cta-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.cta-desc { font-size: 12px; color: var(--text-secondary); }
/* 侧栏统计 */
.sidebar-stats-section { padding-top: 15px; }
.stats-header { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
.stats-header-right { font-size: 12px; font-weight: 400; color: var(--text-muted); padding-right: 3px; }
.stats-subtitle { font-size: 13px; color: var(--text-muted); margin: -4px 0 5px 25px; }
.sidebar-stats { display: flex; flex-direction: column; gap: 12px; padding: 12px; background: var(--bg-hover); border-radius: 8px; }
.sidebar-stat-item { display: flex; justify-content: space-between; align-items: center; }
.sidebar-stat-num { font-size: 15px; font-weight: 700; color: var(--kw-pill-color); }
html.dark .sidebar-stat-num { color: #6ab0e0; }
.sidebar-stat-label { font-size: 14px; color: var(--text-muted); }
/* 反馈入口 */
.sidebar-feedback {
display: flex; align-items: center; gap: 8px;
margin-top: 12px; padding: 10px 14px;
border-radius: 8px;
font-size: 14px; font-weight: 500; color: var(--kw-pill-color);
cursor: pointer; user-select: none;
border: 1px dashed var(--kw-pill-color);
background: color-mix(in srgb, var(--kw-pill-color) 6%, transparent);
transition: all .2s;
}
.sidebar-feedback:hover {
background: color-mix(in srgb, var(--kw-pill-color) 14%, transparent);
border-style: solid;
}
html.dark .sidebar-feedback { color: #6ab0e0; border-color: #6ab0e0; background: color-mix(in srgb, #6ab0e0 8%, transparent); }
html.dark .sidebar-feedback:hover { background: color-mix(in srgb, #6ab0e0 18%, transparent); }
.feedback-icon { flex-shrink: 0; }
.feedback-arrow { margin-left: auto; flex-shrink: 0; opacity: .5; }
/* 回到顶部 */
.scroll-top {
position: fixed; right: 30px; bottom: 40px; z-index: 999;
width: 42px; height: 42px; border-radius: 50%;
border: none; background: var(--kw-pill-color); color: #fff;
font-size: 20px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.2);
transition: opacity .2s;
}
.scroll-top:hover { background: #154360; }
html.dark .scroll-top { background: #2a5a8a; }
html.dark .scroll-top:hover { background: #1a4a7a; }
@media (max-width: 768px) {
.home { padding: 0 8px; overflow-x: hidden; max-width: 100%; box-sizing: border-box; }
.search-row { flex-direction: column; align-items: stretch; }
.search-input-group { padding: 6px 10px 6px; }
.search-input { font-size: 16px; padding: 8px 0 4px; }
.section-cta { padding: 8px 0 20px; }
.cta-banner { flex-direction: column; text-align: center; padding: 16px; }
.content-grid { flex-direction: column; gap: 8px; max-width: 100%; width: 100%; align-items: stretch; }
.content-grid-side { max-width: 100%; position: static; margin-top: 0; min-width: 0; width: 100%; }
.content-grid-main { flex: 1; max-width: 100%; min-width: 0; width: 100%; }
section.section { max-width: 100%; min-width: 0; width: 100%; }
.section-hot-side { margin-top: 12px; }
.tag-pill { padding: 3px 10px; }
.tag-bar {
display: flex; flex-wrap: wrap; gap: 4px; overflow: hidden;
max-width: 100%; width: 100%; box-sizing: border-box; padding: 2px 0;
}
.tag-label { flex-shrink: 0; }
.sidebar-stats { margin-top: 8px; flex-direction: row; flex-wrap: wrap; justify-content: space-around; padding: 10px; max-width: 100%; box-sizing: border-box; }
.sidebar-stat-item { flex-direction: column; gap: 2px; }
.search-input-group { max-width: 100%; box-sizing: border-box; }
}
@media (min-width: 1400px) {
.home { max-width: 1200px; padding: 0 32px; }
.sidebar-stat-num { font-size: 19px; }
}
</style>