Files
backend/backup_20260725/HomeView.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

753 lines
32 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, 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>