fix: PubMed搜索合规 — 34项修复 + query_expansion UnboundLocalError + HomeView precision_mode残留
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:
@@ -111,7 +111,7 @@ const nctId = computed(() => {
|
||||
// 从查询中提取纯文本词(去掉 PubMed 字段标签如 [TI]、[AB] 等)
|
||||
function extractPlainText(q: string): string {
|
||||
return q
|
||||
.replace(/\[[\w-]+\]/g, '') // [field tags]
|
||||
.replace(/\[[\w:-]+\]/g, '') // [field tags] including [MH:noexp]
|
||||
.replace(/"?\b(AND|OR|NOT)\b"?/gi, '')// boolean operators
|
||||
.replace(/[()]/g, '') // P3-4: 去掉括号
|
||||
.replace(/#\d+/g, '') // P3-4: 去掉 #N 引用标记
|
||||
@@ -127,7 +127,8 @@ const highlightedTitle = computed(() => {
|
||||
const plain = extractPlainText(raw)
|
||||
if (!plain) return ''
|
||||
// 拆分为独立词项,逐词高亮(多词查询不拼成一个连写短语)
|
||||
const terms = plain.split(/\s+/).filter(t => t.length > 0)
|
||||
// P1-7: 限制最多 20 个高亮词,防止正则 ReDoS
|
||||
const terms = plain.split(/\s+/).filter(t => t.length > 0).slice(0, 20)
|
||||
if (terms.length === 0) return ''
|
||||
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
// P3-3: 单字符词添加 \b 词边界,但 CJK 字符跳过(\b 对 CJK 无效)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,7 +25,6 @@ const searchParams = ref({
|
||||
date_to: null as string | null,
|
||||
retracted: '',
|
||||
negative_result: '',
|
||||
precision_mode: 'majr',
|
||||
page_size: 20,
|
||||
sort: 'date',
|
||||
})
|
||||
@@ -148,7 +147,6 @@ async function fetchData(resetPage = true) {
|
||||
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
|
||||
@@ -225,11 +223,13 @@ function clearSearch() {
|
||||
localQuery.value = ''
|
||||
searchParams.value = {
|
||||
query: '', field: 'all', tag_ids: [], date_from: null, date_to: null,
|
||||
retracted: '', negative_result: '', precision_mode: 'majr',
|
||||
retracted: '', negative_result: '',
|
||||
page_size: 20, sort: 'date',
|
||||
}
|
||||
selectedTagIds.value = []
|
||||
showAdvanced.value = false
|
||||
feedItems.value = []
|
||||
loadHomepageFeed()
|
||||
}
|
||||
|
||||
// ── 高级搜索 ──
|
||||
@@ -242,7 +242,6 @@ function handleAdvancedSearch(params: {
|
||||
tag_ids: string[]
|
||||
retracted: string
|
||||
negative_result: string
|
||||
precision_mode: string
|
||||
sort: string
|
||||
}) {
|
||||
showAdvanced.value = false
|
||||
@@ -256,7 +255,6 @@ function handleAdvancedSearch(params: {
|
||||
if (params.retracted) q.retracted = params.retracted
|
||||
if (params.negative_result) q.negative = params.negative_result
|
||||
if (params.sort !== 'date') q.sort = params.sort
|
||||
if (params.precision_mode !== 'majr') q.precision = params.precision_mode
|
||||
router.push({ name: 'public-search', query: q })
|
||||
}
|
||||
|
||||
@@ -349,17 +347,16 @@ async function loadMore() {
|
||||
// ── 从 URL 恢复状态 ──
|
||||
function restoreFromUrl() {
|
||||
const tag = route.query.tag as string
|
||||
const date = route.query.date as string
|
||||
const dateFrom = route.query.date_from as string
|
||||
const dateTo = route.query.date_to 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 (dateFrom) searchParams.value.date_from = dateFrom
|
||||
if (dateTo) searchParams.value.date_to = dateTo
|
||||
if (q) {
|
||||
searchParams.value.query = q
|
||||
localQuery.value = q
|
||||
@@ -376,7 +373,8 @@ 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.date_from) query.date_from = searchParams.value.date_from
|
||||
if (searchParams.value.date_to) query.date_to = searchParams.value.date_to
|
||||
if (searchParams.value.query) query.q = searchParams.value.query
|
||||
router.replace({ query })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user