Files
backend/frontend/src/composables/useSearchHistory.ts
T
34047007@qq.com 090938fa2b fix: 第11轮深度审计修复 — P0 keyset翻页崩溃 + P1布尔符语义/语法误报等16项修复
P0 (1项):
- keyset cursor: lit.title or "__NULL__" 空字符串误判为NULL导致后续翻页全空

P1 (6项):
- boolean_operator: 仅扫描depth=0的token,括号内AND/OR不再影响顶层操作符判定
- is_pubmed_syntax: 去除AND/OR/NOT检测的IGNORECASE(PubMed仅识别大写布尔符)
- _relevance_query: MeSH-only查询(如breast[MAJR])补充原始回退,避免退化到日期排序
- pmid_terms: 缺少try/except int(),补充ValueError+DOI兜底
- 部分日期展开: 2024-01[DP]等YYYY-MM partial date展开为整月范围(DP/EDAT/CRDT)
- 前端#N去重: resolveQuery/expandQuery dedup refs防重复引用误判为循环

P2 (9项):
- cache: filter-options加入invalidate_search_cache清理
- worker: daily_ftp_update/daily_citation_update异常时finally保证缓存清理
- cursor_val: 空字符串''通过is None检查,补充not cursor_val
- keyset UI: 模板条件sort==='date'改为KEYSET_SORTS.has(sort)
- resetAllFilters: 补充showCustomYear=false
2026-07-28 12:08:17 +08:00

137 lines
3.7 KiB
TypeScript

import { ref } from 'vue'
const STORAGE_KEY = 'pub_search_history'
const MAX_ENTRIES = 50
export interface HistoryEntry {
id: string
query: string
expanded_query: string
result_count: number | null
timestamp: string
}
function loadAll(): HistoryEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveAll(entries: HistoryEntry[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
}
/** 把 #N 引用替换为 expanded_query(加括号保护优先级) */
export function resolveQuery(query: string, entries: HistoryEntry[]): string {
return query.replace(/#(\d+)/g, (_m, num) => {
const found = entries.find(e => e.id === `#${num}`)
return found ? `(${found.expanded_query})` : _m
})
}
/** 递归展开所有 #N 引用为纯查询,带循环引用检测 */
export function expandQuery(query: string, entries: HistoryEntry[]): string {
const seen = new Set<string>()
let prev = ''
let current = query
for (let i = 0; i < 10; i++) {
if (current === prev) break
const refs = current.match(/#(\d+)/g)
if (refs) {
const uniqueRefs = [...new Set(refs)]
for (const ref of uniqueRefs) {
if (seen.has(ref)) return prev // 循环引用 → 返回上次安全结果
seen.add(ref)
}
}
prev = current
current = resolveQuery(current, entries)
}
return current
}
/** 重新计算所有条目的 expanded_query,在删除/淘汰后保证一致性 */
function recomputeExpanded(entries: HistoryEntry[]) {
const ids = new Set(entries.map(e => e.id))
for (const entry of entries) {
entry.expanded_query = expandQuery(entry.query, entries)
.replace(/#(\d+)/g, (m) => ids.has(m) ? m : '(deleted)')
}
}
export function useSearchHistory() {
const entries = ref<HistoryEntry[]>(loadAll())
function getAll(): HistoryEntry[] {
return [...entries.value]
}
function add(query: string, resultCount?: number | null): HistoryEntry {
const all = loadAll()
const nextNum = all.length > 0
? Math.max(...all.map(e => parseInt(e.id.slice(1), 10))) + 1
: 1
const id = `#${nextNum}`
const expanded = expandQuery(query, all)
const entry: HistoryEntry = {
id,
query,
expanded_query: expanded,
result_count: resultCount ?? null,
timestamp: new Date().toISOString(),
}
if (all.length >= MAX_ENTRIES) {
all.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
all.shift()
recomputeExpanded(all) // 清除悬空 #N 引用
}
all.push(entry)
saveAll(all)
entries.value = [...all]
return entry
}
function remove(id: string) {
const all = loadAll().filter(e => e.id !== id)
recomputeExpanded(all) // 重新展开,清除悬空 #N 引用
saveAll(all)
entries.value = all
}
function clear() {
localStorage.removeItem(STORAGE_KEY)
entries.value = []
}
function download() {
const all = loadAll()
/** 转义 TSV 特殊字符:制表符/换行/回车 → 空格 */
const escapeTsv = (s: string) => s.replace(/[\t\n\r]/g, ' ')
const lines = all.map(e =>
`${e.id}\t${e.result_count ?? ''}\t${e.timestamp}\t${escapeTsv(e.query)}`
)
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `pubmed-search-history-${new Date().toISOString().slice(0, 10)}.tsv`
a.click()
// 延迟回收 blob URL,确保浏览器已开始下载
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
return {
entries,
getAll,
add,
remove,
clear,
download,
}
}