From 723c4fc5c9bdae771768193161fe55272a2bf2b8 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Mon, 27 Jul 2026 09:45:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20PubMed=E6=90=9C=E7=B4=A2=E5=90=88?= =?UTF-8?q?=E8=A7=84=20=E2=80=94=2034=E9=A1=B9=E4=BF=AE=E5=A4=8D=20+=20que?= =?UTF-8?q?ry=5Fexpansion=20UnboundLocalError=20+=20HomeView=20precision?= =?UTF-8?q?=5Fmode=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 残留引用 --- backend/app/api/v1/features.py | 12 +++- backend/app/services/pubmed_query_parser.py | 37 +++++++---- backend/app/services/query_expansion.py | 30 ++++++++- backend/app/services/search_engine.py | 34 ++++++----- backend/tests/test_comprehensive_verify.py | 11 ++-- .../components/literature/LiteratureCard.vue | 5 +- frontend/src/views/app/SearchView.vue | 61 ++++++++++--------- frontend/src/views/public/HomeView.vue | 20 +++--- 8 files changed, 129 insertions(+), 81 deletions(-) diff --git a/backend/app/api/v1/features.py b/backend/app/api/v1/features.py index 2abc8a4..e97449f 100644 --- a/backend/app/api/v1/features.py +++ b/backend/app/api/v1/features.py @@ -1,8 +1,11 @@ """规则引擎 + 每日摘要 + 高级搜索 API""" +import logging import uuid from fastapi import APIRouter, Depends, HTTPException, Query + +logger = logging.getLogger(__name__) from pydantic import BaseModel, Field, field_validator from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -61,7 +64,6 @@ class AdvancedSearchRequest(BaseModel): is_oa: bool | None = None language: str | None = None languages: list[str] | None = None - precision_mode: str = "majr" nlm_subsets: list[str] | None = None page: int = Field(1, ge=1) page_size: int = Field(20, ge=1, le=100) @@ -89,6 +91,7 @@ class AdvancedSearchRequest(BaseModel): @field_validator('query') @classmethod def check_query_complexity(cls, v: str) -> str: + # 粗略检查(split 计数与 tokeniser 不完全一致),精确限制由 tokeniser 的 MAX_TERMS=100 执行 if len(v.split()) > 100: raise ValueError('查询词过多(最多 100 个词),请简化搜索条件') return v @@ -172,6 +175,7 @@ async def _load_filter_options(db: AsyncSession) -> dict: languages = [{"code": r[0], "count": r[1]} for r in lang_rows] # 3. nlm_subsets(单次 JOIN + COUNT FILTER,代替 8 次独立查询) + # 注:code 来自上方 NLM_SUBSET_LABELS 硬编码常量,无注入风险 subset_filter_cols = ", ".join( f'COUNT(*) FILTER (WHERE j.nlm_subsets @> ARRAY[\'{code}\']) AS "{code}"' for code in NLM_SUBSET_LABELS @@ -246,9 +250,11 @@ async def _load_filter_options(db: AsyncSession) -> dict: async def advanced_search(req: AdvancedSearchRequest, db: AsyncSession = Depends(get_db)): try: return await AdvancedSearchEngine.search(db, **req.model_dump()) - except ValueError: - raise HTTPException(status_code=400, detail="搜索参数错误,请检查输入") from None + except ValueError as ve: + logger.warning("搜索参数错误: %s", ve) + raise HTTPException(status_code=400, detail="搜索参数错误,请检查输入") except Exception as e: + logger.exception("搜索服务内部错误") raise HTTPException(status_code=500, detail="搜索服务内部错误") from e diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index b4cb1e1..ca62b0b 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -19,6 +19,7 @@ from __future__ import annotations import re +import unicodedata from dataclasses import dataclass, field from enum import Enum, auto @@ -127,7 +128,7 @@ _TOKEN_PATTERNS: list[tuple[TokenType, str]] = [ _TOKEN_RE = re.compile( '|'.join(f'(?P<{t.name}>{p})' for t, p in _TOKEN_PATTERNS), - re.IGNORECASE, + re.IGNORECASE | re.ASCII, ) @@ -140,7 +141,11 @@ def tokenise(query: str) -> list[Token]: ttype = TokenType[name] if ttype == TokenType.UNKNOWN_FIELD: fname = value.strip('[]').upper() - raise ParseError(f"不认识的字段标签 [{fname}],降级为简单文本搜索") + # P0-1: 不认识字段标签时降级为 WORD,不终止解析 + tokens.append(Token(TokenType.WORD, value.strip('[]'))) + if len(tokens) > MAX_TERMS: + raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索") + continue tokens.append(Token(ttype, value)) if len(tokens) > MAX_TERMS: raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索") @@ -169,11 +174,6 @@ class ParsedPubmedQuery: tiab_terms: list[Term] = field(default_factory=list) # [TIAB] author_terms: list[Term] = field(default_factory=list) # [AU] journal_terms: list[Term] = field(default_factory=list) # [TA] - mesh_terms: list[str] = field(default_factory=list) # [MH] - majr_terms: list[str] = field(default_factory=list) # [MAJR] - pub_types: list[str] = field(default_factory=list) # [PT] - doi_terms: list[str] = field(default_factory=list) # [DOI] - pmid_terms: list[int] = field(default_factory=list) # [PMID] affiliation_terms: list[Term] = field(default_factory=list) # [AD] language_terms: list[Term] = field(default_factory=list) # [LA] volume_terms: list[Term] = field(default_factory=list) # [VI] @@ -275,10 +275,8 @@ class PubmedQueryParser: """入口:解析完整的查询字符串。""" result = ParsedPubmedQuery() self._depth = 0 # 括号嵌套深度计数器 - try: - terms = self._parse_or_expr(result) - except ParseError: - return ParsedPubmedQuery() + terms = self._parse_or_expr(result) + # 解析错误由 parse_pubmed_query 统一降级处理 # Detect boolean operator from token stream has_and = any(t.type == TokenType.AND for t in self.tokens) @@ -296,6 +294,13 @@ class PubmedQueryParser: for t in _ungrouped: self._dispatch_term(result, t) + # P2-1: Handle unconsumed tokens (e.g., orphan text after RPAREN) + if self.pos < len(self.tokens) - 1: + for t in self.tokens[self.pos:-1]: # exclude EOF token + if t.type in (TokenType.WORD, TokenType.QUOTED, TokenType.NUMBER): + text = t.value.strip('"') if t.type == TokenType.QUOTED else t.value + result.plain_terms.append(Term(text=text, exact=(t.type == TokenType.QUOTED))) + return result def _dispatch_term(self, result: ParsedPubmedQuery, term: Term) -> None: @@ -574,6 +579,8 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery: return ParsedPubmedQuery() try: + # P2-3: Unicode normalization — strip zero-width chars, normalize fullwidth digits + query = unicodedata.normalize('NFKC', query) # 将 YYYY/MM/DD 格式的日期分隔符统一为 YYYY-MM-DD,使 tokeniser 正确识别为 DATE import re as _re query = _re.sub(r'(\d{4})/(\d{2})/(\d{2})', r'\1-\2-\3', query) @@ -581,7 +588,11 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery: parser = PubmedQueryParser(tokens) return parser.parse() except (ParseError, IndexError, ValueError): - return ParsedPubmedQuery() + # P0-1: 降级时返回原始查询作为 plain_terms,不丢失用户输入 + degraded = ParsedPubmedQuery() + for t in query.strip().split(): + degraded.plain_terms.append(Term(text=t)) + return degraded def extract_pubmed_query_for_prisma(query: str) -> tuple[str, list[str]]: @@ -598,7 +609,7 @@ def extract_pubmed_query_for_prisma(query: str) -> tuple[str, list[str]]: # 标准化:统一字段大写 normalized = re.sub( - r'\[(\w+)\]', + r'\[([\w:]+)\]', lambda m: f'[{m.group(1).upper()}]', query, ) diff --git a/backend/app/services/query_expansion.py b/backend/app/services/query_expansion.py index 630abf5..063acc7 100644 --- a/backend/app/services/query_expansion.py +++ b/backend/app/services/query_expansion.py @@ -17,6 +17,7 @@ """ import logging +import re from uuid import UUID from sqlalchemy import or_ as _or_, select @@ -81,18 +82,30 @@ async def _find_mesh_tags(db: AsyncSession, query: str) -> list[UUID]: tag_ids.append(tid) seen.add(tid) - # 方法 B: name_en ILIKE 匹配 + # 方法 B: name_en ILIKE 匹配(P1-3: 加 LIMIT 100 防止常见词匹配过多) like_pattern = f"%{_escape_ilike(query)}%" stmt = select(GlobalTag.id).where( GlobalTag.source.in_(["mesh", "manual"]), GlobalTag.name_en.ilike(like_pattern), - ) + ).limit(100) rows = await db.execute(stmt) for (tid,) in rows: if tid not in seen: tag_ids.append(tid) seen.add(tid) + # 方法 C: name_zh ILIKE 匹配(P0-4: 中文查询降级) + if not tag_ids and re.search(r'[一-鿿]', q): + stmt = select(GlobalTag.id).where( + GlobalTag.source.in_(["mesh", "manual"]), + GlobalTag.name_zh.ilike(like_pattern), + ) + rows = await db.execute(stmt) + for (tid,) in rows: + if tid not in seen: + tag_ids.append(tid) + seen.add(tid) + return tag_ids @@ -103,7 +116,18 @@ async def _find_partial_mesh_tags(db: AsyncSession, query: str) -> list[UUID]: """ words = [w.strip().lower() for w in query.strip().split() if len(w.strip()) >= MIN_QUERY_LENGTH][:10] if len(words) < 2: - return [] + # P0-4: 中文查询无法按空格分词,尝试整体 name_zh ILIKE 匹配 + if re.search(r'[一-鿿]', query): + tag_ids = [] + stmt = select(GlobalTag.id).where( + GlobalTag.source.in_(["mesh", "manual"]), + GlobalTag.name_zh.ilike(f'%{_escape_ilike(query.strip().lower())}%'), + ) + rows = await db.execute(stmt) + for (tid,) in rows: + tag_ids.append(tid) + return tag_ids + return [] # non-Chinese single word: no partial match possible seen: set[UUID] = set() tag_ids: list[UUID] = [] diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index b9671e1..390ea8b 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -102,7 +102,6 @@ class AdvancedSearchEngine: is_oa: bool | None = None, # True = 仅开放获取 language: str | None = None, # 语言代码(en/zh/fr 等,向后兼容) languages: list[str] | None = None, # 语言代码列表(多选) - precision_mode: str = "majr", nlm_subsets: list[str] | None = None, # NLM 期刊子集(AIM/M/S 等) page: int = 1, page_size: int = 20, @@ -212,7 +211,7 @@ class AdvancedSearchEngine: query = ' '.join(query.split()) # 中文搜索:自动匹配 GlobalTag.name_zh → 注入 tag_ids,跳过 ILIKE import re as _re - _CHINESE_RE = _re.compile(r'[一-鿿]') + _CHINESE_RE = _re.compile(r'[一-鿿㐀-䶿豈-﫿]') if _CHINESE_RE.search(query): tag_matches = (await db.execute( select(GlobalTag.id).where(GlobalTag.name_zh.ilike(f'%{_escape_ilike(query.strip())}%')) @@ -231,23 +230,29 @@ class AdvancedSearchEngine: terms = [p for p in _phrases if p.strip()] + [t for t in _rest if t not in _phrases] # 单数字词:优先 PMID 精确匹配(unique index 5ms 返回) # 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan - numeric_terms = [t for t in terms if t.isdigit() and len(t) <= 15] - text_terms = [t for t in terms if not (t.isdigit() and len(t) <= 15)] + numeric_terms = [t for t in terms if t.isdecimal() and len(t) <= 15] + text_terms = [t for t in terms if not (t.isdecimal() and len(t) <= 15)] if numeric_terms: num_conds = [] - for t in numeric_terms: - if exact_phrase: - # 精确短语模式:跳过 PMID 快速路径,走 ILIKE + if exact_phrase: + # 精确短语模式:跳过 PMID 快速路径,走 ILIKE + for t in numeric_terms: num_conds.append(or_( GlobalLiterature.title.ilike(t), GlobalLiterature.doi.ilike(t), )) - else: + else: + # P1-1: 批量检查所有 PMID(单次 IN 查询替代 N+1 次 SELECT) + numeric_ids = [int(t) for t in numeric_terms] + db_pmids: set[int] = set() + rows = await db.execute( + select(GlobalLiterature.pmid).where(GlobalLiterature.pmid.in_(numeric_ids)) + ) + for (pmid,) in rows: + db_pmids.add(pmid) + for t in numeric_terms: p = int(t) - exists = (await db.execute( - select(GlobalLiterature.id).where(GlobalLiterature.pmid == p).limit(1) - )).scalar_one_or_none() - if exists: + if p in db_pmids: num_conds.append(GlobalLiterature.pmid == p) else: p_like = f"%{_escape_ilike(t)}%" @@ -263,7 +268,7 @@ class AdvancedSearchEngine: # ATM 展开(仅 field="all" 时,字段搜索不应自动扩到 MeSH) _atm_cond = None _atm_query = query.replace('"', '').replace("'", '').strip() - if _atm_query and not _CHINESE_RE.search(_atm_query) and field == "all": + if _atm_query and field == "all": _atm_cond = await _expand_atm(db, _atm_query) _cond_before = len(conditions) @@ -468,9 +473,6 @@ class AdvancedSearchEngine: except Exception: logger.exception("Year counts query failed") year_counts = [] - q = select(GlobalLiterature) - if conditions: - q = q.where(and_(*conditions)) # 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音) _relevance_query = query diff --git a/backend/tests/test_comprehensive_verify.py b/backend/tests/test_comprehensive_verify.py index 213b5a9..e2222c8 100644 --- a/backend/tests/test_comprehensive_verify.py +++ b/backend/tests/test_comprehensive_verify.py @@ -239,13 +239,12 @@ class TestParserGaps: # ── A6: edge cases ── def test_a6c_over_100_tokens(self): - """More than 100 tokens — tokeniser raises ParseError, parser degrades gracefully""" + """More than 100 tokens — tokeniser raises ParseError, parser degrades to plain terms""" words = "word " * 101 r = parse_pubmed_query(words.strip()) assert isinstance(r, ParsedPubmedQuery) - # Tokeniser raises ParseError(ValueError) at >100 tokens, - # parser catches it and returns empty result = graceful degradation - assert len(r.plain_terms) == 0 + # Degradation returns all 101 words as plain_terms (P0-1) + assert len(r.plain_terms) == 101 def test_a6_repeated_AND(self): """AND AND — repeated boolean""" @@ -263,9 +262,11 @@ class TestParserGaps: assert isinstance(r, ParsedPubmedQuery) def test_a6_unknown_field(self): - """cancer[XX] — unknown field tag""" + """cancer[XX] — unknown field tag degrades to plain text (P0-1)""" r = parse_pubmed_query("cancer[XX]") assert isinstance(r, ParsedPubmedQuery) + # P0-1: unknown field tag now emits WORD token instead of aborting + assert len(r.plain_terms) >= 1 class TestFieldTagCompleteness: diff --git a/frontend/src/components/literature/LiteratureCard.vue b/frontend/src/components/literature/LiteratureCard.vue index c6df112..b8a4ffa 100644 --- a/frontend/src/components/literature/LiteratureCard.vue +++ b/frontend/src/components/literature/LiteratureCard.vue @@ -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 无效) diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index 1b050b0..2ea3f0a 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -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(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 || {})