fix: PubMed搜索合规 — 34项修复 + query_expansion UnboundLocalError + HomeView precision_mode残留
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

Batch1 — 解析器 (pubmed_query_parser.py)
- P0-1: 未知字段标签降级为 WORD token 而非 ParseError
- P2-1: 增加未消费 token 检查
- P2-2: PRISMA 字段标签正则 [\w:+] → [\w:]+
- P2-3: Unicode NFKC 规格化输入
- P2-4: re.ASCII 防止 Unicode 数字匹配
- P2-9: 移除重复 dataclass 字段

Batch2 — 搜索引擎 (search_engine.py)
- P1-1: 批量 PMID 查询替代 N+1 循环
- P1-2: isdigit() → isdecimal()
- P1-5: 移除 precision_mode 参数
- P2-5: 移除死代码
- P2-6: 统一 _CHINESE_RE 正则

Batch3 — ATM 引擎 (query_expansion.py)
- P0-4: name_zh ILIKE 中文回退 + _find_mesh_tags 中文降级
- P1-3: name_en ILIKE 加 LIMIT 100

Batch4 — API 层 (features.py)
- P1-5: 移除 precision_mode 请求字段
- P1-11: 增加 logging
- P2-8: NLM_SUBSET_LABELS f-string 安全注释
- P2-12: split 校验器近似性注释

Batch5 — SearchView.vue
- P0-5a/b/c: 日期修复(UTC 方法、互斥逻辑、restoreFromQuery 合并)
- P2-10: 筛选模态关闭时重搜
- P3-1: 搜索框 aria-label

Batch6 — HomeView.vue
- P1-10: URL date → date_from/date_to
- P2-11: clearSearch 清空 feedItems 并重加载

Batch7 — LiteratureCard.vue
- P1-6: 字段标签正则 [\w-]+ → [\w:-]+
- P1-7: terms 切片限制 20 项防 ReDoS

后修复:
- query_expansion.py _find_partial_mesh_tags UnboundLocalError(单非中文词未初始化 tag_ids)
- HomeView.vue handleAdvancedSearch precision_mode 残留引用
This commit is contained in:
34047007@qq.com
2026-07-27 09:45:17 +08:00
parent 43392438c8
commit 723c4fc5c9
8 changed files with 129 additions and 81 deletions
+33 -28
View File
@@ -50,7 +50,6 @@ const hasFullText = ref(false)
const hasAssociatedData = ref(false)
const medlineOnly = ref(false)
const excludePreprints = ref(false)
const precisionMode = ref('majr') // 'majr' | 'mesh'
// ── 筛选选项数据 ──
const filterOptions = ref<any>(null)
@@ -294,17 +293,18 @@ const { page, total, goToPage } = usePagination({
// cursor 不存在时保留 body.page,回退到 offset 分页
}
}
// 年份 / 日期
// 年份 / 日期datePreset 与 year_* 互斥)
const yf = Number(yearFromStr.value)
const yt = Number(yearToStr.value)
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
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') {
body.date_to = new Date().toISOString().slice(0, 10)
const d = new Date()
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
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
@@ -369,15 +369,16 @@ function restoreFromQuery() {
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
if (route.query.date_preset && ['1y','5y','10y'].includes(String(route.query.date_preset))) {
datePreset.value = String(route.query.date_preset)
} else if (route.query.date_from) {
} else if (route.query.date_from || route.query.date_to) {
datePreset.value = null
const df = String(route.query.date_from)
if (df.length >= 4) yearFromStr.value = df.slice(0, 4)
}
if (route.query.date_to) {
datePreset.value = null
const dt = String(route.query.date_to)
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
if (route.query.date_from) {
const df = String(route.query.date_from)
if (df.length >= 4) yearFromStr.value = df.slice(0, 4)
}
if (route.query.date_to) {
const dt = String(route.query.date_to)
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
}
}
if (route.query.tag) selectedTags.value = String(route.query.tag).split(',')
if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',')
@@ -395,7 +396,6 @@ function restoreFromQuery() {
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.precision) precisionMode.value = String(route.query.precision)
if (route.query.p) restoredPage.value = parseInt(String(route.query.p)) || 1
// keyset 游标不能从 URL 恢复 → page>1 回退到首页
if (sort.value === 'date' && restoredPage.value > 1) restoredPage.value = 1
@@ -410,6 +410,11 @@ watch(datePreset, (val) => {
}
})
// 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))
@@ -451,17 +456,19 @@ function syncSearchToUrl() {
if (query.value) q.q = query.value
if (field.value !== 'all') q.field = field.value
if (sort.value !== 'date') q.sort = sort.value
if (yearFromStr.value) q.year_from = yearFromStr.value
if (yearToStr.value) q.year_to = yearToStr.value
if (datePreset.value && datePreset.value !== 'custom') {
q.date_preset = datePreset.value
// 同时保存计算出的 date_from/date_to,使分享的 URL 能直接恢复
q.date_to = new Date().toISOString().slice(0, 10)
const d = new Date()
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
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 (yearFromStr.value) q.year_from = yearFromStr.value
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(',')
@@ -479,7 +486,6 @@ function syncSearchToUrl() {
if (hasAssociatedData.value) q.associated_data = 'true'
if (medlineOnly.value) q.medline_only = 'true'
if (excludePreprints.value) q.exclude_preprints = 'true'
if (precisionMode.value !== 'majr') q.precision = precisionMode.value
// 页码持久化到 URL(不同排序下使用各自的分页)
const currentPage = sort.value === 'date' ? keysetPage.value : page.value
if (currentPage > 1) q.p = String(currentPage)
@@ -505,7 +511,6 @@ function resetAllFilters() {
hasAssociatedData.value = false
medlineOnly.value = false
excludePreprints.value = false
precisionMode.value = 'majr'
query.value = ''
field.value = 'all'
sort.value = 'date'
@@ -793,7 +798,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
<!-- ======== 右侧搜索结果 ======== -->
<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 @keyup.enter="goToPage(1)" /></div>
<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="field" :options="[{label:'全部',value:'all'},{label:'标题',value:'title'},{label:'摘要',value:'abstract'},{label:'作者',value:'author'},{label:'机构',value:'affiliation'},{label:'期刊',value:'journal'}]" size="small" />
<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>