2026-07-27 08:35:12 +08:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 11:02:29 +08:00
|
|
|
/** 递归展开所有 #N 引用为纯查询,带循环引用检测 */
|
2026-07-27 08:35:12 +08:00
|
|
|
export function expandQuery(query: string, entries: HistoryEntry[]): string {
|
2026-07-28 11:02:29 +08:00
|
|
|
const seen = new Set<string>()
|
2026-07-27 08:35:12 +08:00
|
|
|
let prev = ''
|
|
|
|
|
let current = query
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
if (current === prev) break
|
2026-07-28 11:02:29 +08:00
|
|
|
const refs = current.match(/#(\d+)/g)
|
|
|
|
|
if (refs) {
|
2026-07-28 12:08:17 +08:00
|
|
|
const uniqueRefs = [...new Set(refs)]
|
|
|
|
|
for (const ref of uniqueRefs) {
|
2026-07-28 11:02:29 +08:00
|
|
|
if (seen.has(ref)) return prev // 循环引用 → 返回上次安全结果
|
|
|
|
|
seen.add(ref)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 08:35:12 +08:00
|
|
|
prev = current
|
|
|
|
|
current = resolveQuery(current, entries)
|
|
|
|
|
}
|
|
|
|
|
return current
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 11:02:29 +08:00
|
|
|
/** 重新计算所有条目的 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)')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:35:12 +08:00
|
|
|
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()
|
2026-07-28 11:02:29 +08:00
|
|
|
recomputeExpanded(all) // 清除悬空 #N 引用
|
2026-07-27 08:35:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
all.push(entry)
|
|
|
|
|
saveAll(all)
|
|
|
|
|
entries.value = [...all]
|
|
|
|
|
return entry
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function remove(id: string) {
|
|
|
|
|
const all = loadAll().filter(e => e.id !== id)
|
2026-07-28 11:02:29 +08:00
|
|
|
recomputeExpanded(all) // 重新展开,清除悬空 #N 引用
|
2026-07-27 08:35:12 +08:00
|
|
|
saveAll(all)
|
|
|
|
|
entries.value = all
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clear() {
|
|
|
|
|
localStorage.removeItem(STORAGE_KEY)
|
|
|
|
|
entries.value = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function download() {
|
|
|
|
|
const all = loadAll()
|
2026-07-28 11:02:29 +08:00
|
|
|
/** 转义 TSV 特殊字符:制表符/换行/回车 → 空格 */
|
|
|
|
|
const escapeTsv = (s: string) => s.replace(/[\t\n\r]/g, ' ')
|
2026-07-27 08:35:12 +08:00
|
|
|
const lines = all.map(e =>
|
2026-07-28 11:02:29 +08:00
|
|
|
`${e.id}\t${e.result_count ?? ''}\t${e.timestamp}\t${escapeTsv(e.query)}`
|
2026-07-27 08:35:12 +08:00
|
|
|
)
|
|
|
|
|
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()
|
2026-07-28 11:02:29 +08:00
|
|
|
// 延迟回收 blob URL,确保浏览器已开始下载
|
|
|
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
2026-07-27 08:35:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
entries,
|
|
|
|
|
getAll,
|
|
|
|
|
add,
|
|
|
|
|
remove,
|
|
|
|
|
clear,
|
|
|
|
|
download,
|
|
|
|
|
}
|
|
|
|
|
}
|