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:
@@ -1,8 +1,11 @@
|
|||||||
"""规则引擎 + 每日摘要 + 高级搜索 API"""
|
"""规则引擎 + 每日摘要 + 高级搜索 API"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -61,7 +64,6 @@ class AdvancedSearchRequest(BaseModel):
|
|||||||
is_oa: bool | None = None
|
is_oa: bool | None = None
|
||||||
language: str | None = None
|
language: str | None = None
|
||||||
languages: list[str] | None = None
|
languages: list[str] | None = None
|
||||||
precision_mode: str = "majr"
|
|
||||||
nlm_subsets: list[str] | None = None
|
nlm_subsets: list[str] | None = None
|
||||||
page: int = Field(1, ge=1)
|
page: int = Field(1, ge=1)
|
||||||
page_size: int = Field(20, ge=1, le=100)
|
page_size: int = Field(20, ge=1, le=100)
|
||||||
@@ -89,6 +91,7 @@ class AdvancedSearchRequest(BaseModel):
|
|||||||
@field_validator('query')
|
@field_validator('query')
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_query_complexity(cls, v: str) -> str:
|
def check_query_complexity(cls, v: str) -> str:
|
||||||
|
# 粗略检查(split 计数与 tokeniser 不完全一致),精确限制由 tokeniser 的 MAX_TERMS=100 执行
|
||||||
if len(v.split()) > 100:
|
if len(v.split()) > 100:
|
||||||
raise ValueError('查询词过多(最多 100 个词),请简化搜索条件')
|
raise ValueError('查询词过多(最多 100 个词),请简化搜索条件')
|
||||||
return v
|
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]
|
languages = [{"code": r[0], "count": r[1]} for r in lang_rows]
|
||||||
|
|
||||||
# 3. nlm_subsets(单次 JOIN + COUNT FILTER,代替 8 次独立查询)
|
# 3. nlm_subsets(单次 JOIN + COUNT FILTER,代替 8 次独立查询)
|
||||||
|
# 注:code 来自上方 NLM_SUBSET_LABELS 硬编码常量,无注入风险
|
||||||
subset_filter_cols = ", ".join(
|
subset_filter_cols = ", ".join(
|
||||||
f'COUNT(*) FILTER (WHERE j.nlm_subsets @> ARRAY[\'{code}\']) AS "{code}"'
|
f'COUNT(*) FILTER (WHERE j.nlm_subsets @> ARRAY[\'{code}\']) AS "{code}"'
|
||||||
for code in NLM_SUBSET_LABELS
|
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)):
|
async def advanced_search(req: AdvancedSearchRequest, db: AsyncSession = Depends(get_db)):
|
||||||
try:
|
try:
|
||||||
return await AdvancedSearchEngine.search(db, **req.model_dump())
|
return await AdvancedSearchEngine.search(db, **req.model_dump())
|
||||||
except ValueError:
|
except ValueError as ve:
|
||||||
raise HTTPException(status_code=400, detail="搜索参数错误,请检查输入") from None
|
logger.warning("搜索参数错误: %s", ve)
|
||||||
|
raise HTTPException(status_code=400, detail="搜索参数错误,请检查输入")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception("搜索服务内部错误")
|
||||||
raise HTTPException(status_code=500, detail="搜索服务内部错误") from e
|
raise HTTPException(status_code=500, detail="搜索服务内部错误") from e
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ _TOKEN_PATTERNS: list[tuple[TokenType, str]] = [
|
|||||||
|
|
||||||
_TOKEN_RE = re.compile(
|
_TOKEN_RE = re.compile(
|
||||||
'|'.join(f'(?P<{t.name}>{p})' for t, p in _TOKEN_PATTERNS),
|
'|'.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]
|
ttype = TokenType[name]
|
||||||
if ttype == TokenType.UNKNOWN_FIELD:
|
if ttype == TokenType.UNKNOWN_FIELD:
|
||||||
fname = value.strip('[]').upper()
|
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))
|
tokens.append(Token(ttype, value))
|
||||||
if len(tokens) > MAX_TERMS:
|
if len(tokens) > MAX_TERMS:
|
||||||
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
|
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
|
||||||
@@ -169,11 +174,6 @@ class ParsedPubmedQuery:
|
|||||||
tiab_terms: list[Term] = field(default_factory=list) # [TIAB]
|
tiab_terms: list[Term] = field(default_factory=list) # [TIAB]
|
||||||
author_terms: list[Term] = field(default_factory=list) # [AU]
|
author_terms: list[Term] = field(default_factory=list) # [AU]
|
||||||
journal_terms: list[Term] = field(default_factory=list) # [TA]
|
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]
|
affiliation_terms: list[Term] = field(default_factory=list) # [AD]
|
||||||
language_terms: list[Term] = field(default_factory=list) # [LA]
|
language_terms: list[Term] = field(default_factory=list) # [LA]
|
||||||
volume_terms: list[Term] = field(default_factory=list) # [VI]
|
volume_terms: list[Term] = field(default_factory=list) # [VI]
|
||||||
@@ -275,10 +275,8 @@ class PubmedQueryParser:
|
|||||||
"""入口:解析完整的查询字符串。"""
|
"""入口:解析完整的查询字符串。"""
|
||||||
result = ParsedPubmedQuery()
|
result = ParsedPubmedQuery()
|
||||||
self._depth = 0 # 括号嵌套深度计数器
|
self._depth = 0 # 括号嵌套深度计数器
|
||||||
try:
|
terms = self._parse_or_expr(result)
|
||||||
terms = self._parse_or_expr(result)
|
# 解析错误由 parse_pubmed_query 统一降级处理
|
||||||
except ParseError:
|
|
||||||
return ParsedPubmedQuery()
|
|
||||||
|
|
||||||
# Detect boolean operator from token stream
|
# Detect boolean operator from token stream
|
||||||
has_and = any(t.type == TokenType.AND for t in self.tokens)
|
has_and = any(t.type == TokenType.AND for t in self.tokens)
|
||||||
@@ -296,6 +294,13 @@ class PubmedQueryParser:
|
|||||||
for t in _ungrouped:
|
for t in _ungrouped:
|
||||||
self._dispatch_term(result, t)
|
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
|
return result
|
||||||
|
|
||||||
def _dispatch_term(self, result: ParsedPubmedQuery, term: Term) -> None:
|
def _dispatch_term(self, result: ParsedPubmedQuery, term: Term) -> None:
|
||||||
@@ -574,6 +579,8 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery:
|
|||||||
return ParsedPubmedQuery()
|
return ParsedPubmedQuery()
|
||||||
|
|
||||||
try:
|
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
|
# 将 YYYY/MM/DD 格式的日期分隔符统一为 YYYY-MM-DD,使 tokeniser 正确识别为 DATE
|
||||||
import re as _re
|
import re as _re
|
||||||
query = _re.sub(r'(\d{4})/(\d{2})/(\d{2})', r'\1-\2-\3', query)
|
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)
|
parser = PubmedQueryParser(tokens)
|
||||||
return parser.parse()
|
return parser.parse()
|
||||||
except (ParseError, IndexError, ValueError):
|
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]]:
|
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(
|
normalized = re.sub(
|
||||||
r'\[(\w+)\]',
|
r'\[([\w:]+)\]',
|
||||||
lambda m: f'[{m.group(1).upper()}]',
|
lambda m: f'[{m.group(1).upper()}]',
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import or_ as _or_, select
|
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)
|
tag_ids.append(tid)
|
||||||
seen.add(tid)
|
seen.add(tid)
|
||||||
|
|
||||||
# 方法 B: name_en ILIKE 匹配
|
# 方法 B: name_en ILIKE 匹配(P1-3: 加 LIMIT 100 防止常见词匹配过多)
|
||||||
like_pattern = f"%{_escape_ilike(query)}%"
|
like_pattern = f"%{_escape_ilike(query)}%"
|
||||||
stmt = select(GlobalTag.id).where(
|
stmt = select(GlobalTag.id).where(
|
||||||
GlobalTag.source.in_(["mesh", "manual"]),
|
GlobalTag.source.in_(["mesh", "manual"]),
|
||||||
GlobalTag.name_en.ilike(like_pattern),
|
GlobalTag.name_en.ilike(like_pattern),
|
||||||
)
|
).limit(100)
|
||||||
rows = await db.execute(stmt)
|
rows = await db.execute(stmt)
|
||||||
for (tid,) in rows:
|
for (tid,) in rows:
|
||||||
if tid not in seen:
|
if tid not in seen:
|
||||||
tag_ids.append(tid)
|
tag_ids.append(tid)
|
||||||
seen.add(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
|
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]
|
words = [w.strip().lower() for w in query.strip().split() if len(w.strip()) >= MIN_QUERY_LENGTH][:10]
|
||||||
if len(words) < 2:
|
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()
|
seen: set[UUID] = set()
|
||||||
tag_ids: list[UUID] = []
|
tag_ids: list[UUID] = []
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ class AdvancedSearchEngine:
|
|||||||
is_oa: bool | None = None, # True = 仅开放获取
|
is_oa: bool | None = None, # True = 仅开放获取
|
||||||
language: str | None = None, # 语言代码(en/zh/fr 等,向后兼容)
|
language: str | None = None, # 语言代码(en/zh/fr 等,向后兼容)
|
||||||
languages: list[str] | None = None, # 语言代码列表(多选)
|
languages: list[str] | None = None, # 语言代码列表(多选)
|
||||||
precision_mode: str = "majr",
|
|
||||||
nlm_subsets: list[str] | None = None, # NLM 期刊子集(AIM/M/S 等)
|
nlm_subsets: list[str] | None = None, # NLM 期刊子集(AIM/M/S 等)
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
@@ -212,7 +211,7 @@ class AdvancedSearchEngine:
|
|||||||
query = ' '.join(query.split())
|
query = ' '.join(query.split())
|
||||||
# 中文搜索:自动匹配 GlobalTag.name_zh → 注入 tag_ids,跳过 ILIKE
|
# 中文搜索:自动匹配 GlobalTag.name_zh → 注入 tag_ids,跳过 ILIKE
|
||||||
import re as _re
|
import re as _re
|
||||||
_CHINESE_RE = _re.compile(r'[一-鿿]')
|
_CHINESE_RE = _re.compile(r'[一-鿿㐀-䶿豈-]')
|
||||||
if _CHINESE_RE.search(query):
|
if _CHINESE_RE.search(query):
|
||||||
tag_matches = (await db.execute(
|
tag_matches = (await db.execute(
|
||||||
select(GlobalTag.id).where(GlobalTag.name_zh.ilike(f'%{_escape_ilike(query.strip())}%'))
|
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]
|
terms = [p for p in _phrases if p.strip()] + [t for t in _rest if t not in _phrases]
|
||||||
# 单数字词:优先 PMID 精确匹配(unique index 5ms 返回)
|
# 单数字词:优先 PMID 精确匹配(unique index 5ms 返回)
|
||||||
# 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan
|
# 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan
|
||||||
numeric_terms = [t for t in terms if 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.isdigit() and len(t) <= 15)]
|
text_terms = [t for t in terms if not (t.isdecimal() and len(t) <= 15)]
|
||||||
if numeric_terms:
|
if numeric_terms:
|
||||||
num_conds = []
|
num_conds = []
|
||||||
for t in numeric_terms:
|
if exact_phrase:
|
||||||
if exact_phrase:
|
# 精确短语模式:跳过 PMID 快速路径,走 ILIKE
|
||||||
# 精确短语模式:跳过 PMID 快速路径,走 ILIKE
|
for t in numeric_terms:
|
||||||
num_conds.append(or_(
|
num_conds.append(or_(
|
||||||
GlobalLiterature.title.ilike(t),
|
GlobalLiterature.title.ilike(t),
|
||||||
GlobalLiterature.doi.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)
|
p = int(t)
|
||||||
exists = (await db.execute(
|
if p in db_pmids:
|
||||||
select(GlobalLiterature.id).where(GlobalLiterature.pmid == p).limit(1)
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
if exists:
|
|
||||||
num_conds.append(GlobalLiterature.pmid == p)
|
num_conds.append(GlobalLiterature.pmid == p)
|
||||||
else:
|
else:
|
||||||
p_like = f"%{_escape_ilike(t)}%"
|
p_like = f"%{_escape_ilike(t)}%"
|
||||||
@@ -263,7 +268,7 @@ class AdvancedSearchEngine:
|
|||||||
# ATM 展开(仅 field="all" 时,字段搜索不应自动扩到 MeSH)
|
# ATM 展开(仅 field="all" 时,字段搜索不应自动扩到 MeSH)
|
||||||
_atm_cond = None
|
_atm_cond = None
|
||||||
_atm_query = query.replace('"', '').replace("'", '').strip()
|
_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)
|
_atm_cond = await _expand_atm(db, _atm_query)
|
||||||
|
|
||||||
_cond_before = len(conditions)
|
_cond_before = len(conditions)
|
||||||
@@ -468,9 +473,6 @@ class AdvancedSearchEngine:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Year counts query failed")
|
logger.exception("Year counts query failed")
|
||||||
year_counts = []
|
year_counts = []
|
||||||
q = select(GlobalLiterature)
|
|
||||||
if conditions:
|
|
||||||
q = q.where(and_(*conditions))
|
|
||||||
|
|
||||||
# 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音)
|
# 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音)
|
||||||
_relevance_query = query
|
_relevance_query = query
|
||||||
|
|||||||
@@ -239,13 +239,12 @@ class TestParserGaps:
|
|||||||
# ── A6: edge cases ──
|
# ── A6: edge cases ──
|
||||||
|
|
||||||
def test_a6c_over_100_tokens(self):
|
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
|
words = "word " * 101
|
||||||
r = parse_pubmed_query(words.strip())
|
r = parse_pubmed_query(words.strip())
|
||||||
assert isinstance(r, ParsedPubmedQuery)
|
assert isinstance(r, ParsedPubmedQuery)
|
||||||
# Tokeniser raises ParseError(ValueError) at >100 tokens,
|
# Degradation returns all 101 words as plain_terms (P0-1)
|
||||||
# parser catches it and returns empty result = graceful degradation
|
assert len(r.plain_terms) == 101
|
||||||
assert len(r.plain_terms) == 0
|
|
||||||
|
|
||||||
def test_a6_repeated_AND(self):
|
def test_a6_repeated_AND(self):
|
||||||
"""AND AND — repeated boolean"""
|
"""AND AND — repeated boolean"""
|
||||||
@@ -263,9 +262,11 @@ class TestParserGaps:
|
|||||||
assert isinstance(r, ParsedPubmedQuery)
|
assert isinstance(r, ParsedPubmedQuery)
|
||||||
|
|
||||||
def test_a6_unknown_field(self):
|
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]")
|
r = parse_pubmed_query("cancer[XX]")
|
||||||
assert isinstance(r, ParsedPubmedQuery)
|
assert isinstance(r, ParsedPubmedQuery)
|
||||||
|
# P0-1: unknown field tag now emits WORD token instead of aborting
|
||||||
|
assert len(r.plain_terms) >= 1
|
||||||
|
|
||||||
|
|
||||||
class TestFieldTagCompleteness:
|
class TestFieldTagCompleteness:
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const nctId = computed(() => {
|
|||||||
// 从查询中提取纯文本词(去掉 PubMed 字段标签如 [TI]、[AB] 等)
|
// 从查询中提取纯文本词(去掉 PubMed 字段标签如 [TI]、[AB] 等)
|
||||||
function extractPlainText(q: string): string {
|
function extractPlainText(q: string): string {
|
||||||
return q
|
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(/"?\b(AND|OR|NOT)\b"?/gi, '')// boolean operators
|
||||||
.replace(/[()]/g, '') // P3-4: 去掉括号
|
.replace(/[()]/g, '') // P3-4: 去掉括号
|
||||||
.replace(/#\d+/g, '') // P3-4: 去掉 #N 引用标记
|
.replace(/#\d+/g, '') // P3-4: 去掉 #N 引用标记
|
||||||
@@ -127,7 +127,8 @@ const highlightedTitle = computed(() => {
|
|||||||
const plain = extractPlainText(raw)
|
const plain = extractPlainText(raw)
|
||||||
if (!plain) return ''
|
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 ''
|
if (terms.length === 0) return ''
|
||||||
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||||
// P3-3: 单字符词添加 \b 词边界,但 CJK 字符跳过(\b 对 CJK 无效)
|
// P3-3: 单字符词添加 \b 词边界,但 CJK 字符跳过(\b 对 CJK 无效)
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const hasFullText = ref(false)
|
|||||||
const hasAssociatedData = ref(false)
|
const hasAssociatedData = ref(false)
|
||||||
const medlineOnly = ref(false)
|
const medlineOnly = ref(false)
|
||||||
const excludePreprints = ref(false)
|
const excludePreprints = ref(false)
|
||||||
const precisionMode = ref('majr') // 'majr' | 'mesh'
|
|
||||||
|
|
||||||
// ── 筛选选项数据 ──
|
// ── 筛选选项数据 ──
|
||||||
const filterOptions = ref<any>(null)
|
const filterOptions = ref<any>(null)
|
||||||
@@ -294,17 +293,18 @@ const { page, total, goToPage } = usePagination({
|
|||||||
// cursor 不存在时保留 body.page,回退到 offset 分页
|
// cursor 不存在时保留 body.page,回退到 offset 分页
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 年份 / 日期
|
// 年份 / 日期(datePreset 与 year_* 互斥)
|
||||||
const yf = Number(yearFromStr.value)
|
const yf = Number(yearFromStr.value)
|
||||||
const yt = Number(yearToStr.value)
|
const yt = Number(yearToStr.value)
|
||||||
if (yearFromStr.value && !isNaN(yf) && yf >= 1900 && yf <= 2100) body.year_from = yf
|
if (!datePreset.value && 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 && yearToStr.value && !isNaN(yt) && yt >= 1900 && yt <= 2100) body.year_to = yt
|
||||||
if (datePreset.value && datePreset.value !== 'custom') {
|
if (datePreset.value && datePreset.value !== 'custom') {
|
||||||
body.date_to = new Date().toISOString().slice(0, 10)
|
const now = new Date()
|
||||||
const d = new Date()
|
body.date_to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString().slice(0, 10)
|
||||||
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||||
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
|
if (datePreset.value === '1y') d.setUTCFullYear(d.getUTCFullYear() - 1)
|
||||||
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
|
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)
|
body.date_from = d.toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
// Text Availability
|
// Text Availability
|
||||||
@@ -369,15 +369,16 @@ function restoreFromQuery() {
|
|||||||
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
|
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))) {
|
if (route.query.date_preset && ['1y','5y','10y'].includes(String(route.query.date_preset))) {
|
||||||
datePreset.value = 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
|
datePreset.value = null
|
||||||
const df = String(route.query.date_from)
|
if (route.query.date_from) {
|
||||||
if (df.length >= 4) yearFromStr.value = df.slice(0, 4)
|
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
|
if (route.query.date_to) {
|
||||||
const dt = String(route.query.date_to)
|
const dt = String(route.query.date_to)
|
||||||
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
|
if (dt.length >= 4) yearToStr.value = dt.slice(0, 4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (route.query.tag) selectedTags.value = String(route.query.tag).split(',')
|
if (route.query.tag) selectedTags.value = String(route.query.tag).split(',')
|
||||||
if (route.query.tier) selectedTiers.value = String(route.query.tier).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.associated_data) hasAssociatedData.value = route.query.associated_data === 'true'
|
||||||
if (route.query.medline_only) medlineOnly.value = route.query.medline_only === '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.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
|
if (route.query.p) restoredPage.value = parseInt(String(route.query.p)) || 1
|
||||||
// keyset 游标不能从 URL 恢复 → page>1 回退到首页
|
// keyset 游标不能从 URL 恢复 → page>1 回退到首页
|
||||||
if (sort.value === 'date' && restoredPage.value > 1) restoredPage.value = 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 变化时持久化并重新搜索
|
// pageSize 变化时持久化并重新搜索
|
||||||
watch(pageSize, (v) => {
|
watch(pageSize, (v) => {
|
||||||
localStorage.setItem('search:pageSize', String(v))
|
localStorage.setItem('search:pageSize', String(v))
|
||||||
@@ -451,17 +456,19 @@ function syncSearchToUrl() {
|
|||||||
if (query.value) q.q = query.value
|
if (query.value) q.q = query.value
|
||||||
if (field.value !== 'all') q.field = field.value
|
if (field.value !== 'all') q.field = field.value
|
||||||
if (sort.value !== 'date') q.sort = sort.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') {
|
if (datePreset.value && datePreset.value !== 'custom') {
|
||||||
q.date_preset = datePreset.value
|
q.date_preset = datePreset.value
|
||||||
// 同时保存计算出的 date_from/date_to,使分享的 URL 能直接恢复
|
// 同时保存计算出的 date_from/date_to,使分享的 URL 能直接恢复
|
||||||
q.date_to = new Date().toISOString().slice(0, 10)
|
const now = new Date()
|
||||||
const d = new Date()
|
q.date_to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString().slice(0, 10)
|
||||||
if (datePreset.value === '1y') d.setFullYear(d.getFullYear() - 1)
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||||
else if (datePreset.value === '5y') d.setFullYear(d.getFullYear() - 5)
|
if (datePreset.value === '1y') d.setUTCFullYear(d.getUTCFullYear() - 1)
|
||||||
else if (datePreset.value === '10y') d.setFullYear(d.getFullYear() - 10)
|
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)
|
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 (selectedTags.value.length) q.tag = selectedTags.value.join(',')
|
||||||
if (selectedTiers.value.length) q.tier = selectedTiers.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 (hasAssociatedData.value) q.associated_data = 'true'
|
||||||
if (medlineOnly.value) q.medline_only = 'true'
|
if (medlineOnly.value) q.medline_only = 'true'
|
||||||
if (excludePreprints.value) q.exclude_preprints = 'true'
|
if (excludePreprints.value) q.exclude_preprints = 'true'
|
||||||
if (precisionMode.value !== 'majr') q.precision = precisionMode.value
|
|
||||||
// 页码持久化到 URL(不同排序下使用各自的分页)
|
// 页码持久化到 URL(不同排序下使用各自的分页)
|
||||||
const currentPage = sort.value === 'date' ? keysetPage.value : page.value
|
const currentPage = sort.value === 'date' ? keysetPage.value : page.value
|
||||||
if (currentPage > 1) q.p = String(currentPage)
|
if (currentPage > 1) q.p = String(currentPage)
|
||||||
@@ -505,7 +511,6 @@ function resetAllFilters() {
|
|||||||
hasAssociatedData.value = false
|
hasAssociatedData.value = false
|
||||||
medlineOnly.value = false
|
medlineOnly.value = false
|
||||||
excludePreprints.value = false
|
excludePreprints.value = false
|
||||||
precisionMode.value = 'majr'
|
|
||||||
query.value = ''
|
query.value = ''
|
||||||
field.value = 'all'
|
field.value = 'all'
|
||||||
sort.value = 'date'
|
sort.value = 'date'
|
||||||
@@ -793,7 +798,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
|
|||||||
<!-- ======== 右侧搜索结果 ======== -->
|
<!-- ======== 右侧搜索结果 ======== -->
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div class="search-bar">
|
<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" />
|
<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" 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>
|
<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,
|
date_to: null as string | null,
|
||||||
retracted: '',
|
retracted: '',
|
||||||
negative_result: '',
|
negative_result: '',
|
||||||
precision_mode: 'majr',
|
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
sort: 'date',
|
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.date_to) body.date_to = searchParams.value.date_to
|
||||||
if (searchParams.value.retracted) body.retracted = searchParams.value.retracted
|
if (searchParams.value.retracted) body.retracted = searchParams.value.retracted
|
||||||
if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result
|
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 游标
|
// keyset 游标
|
||||||
if (cursorDate.value && cursorId.value) {
|
if (cursorDate.value && cursorId.value) {
|
||||||
body.cursor_date = cursorDate.value
|
body.cursor_date = cursorDate.value
|
||||||
@@ -225,11 +223,13 @@ function clearSearch() {
|
|||||||
localQuery.value = ''
|
localQuery.value = ''
|
||||||
searchParams.value = {
|
searchParams.value = {
|
||||||
query: '', field: 'all', tag_ids: [], date_from: null, date_to: null,
|
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',
|
page_size: 20, sort: 'date',
|
||||||
}
|
}
|
||||||
selectedTagIds.value = []
|
selectedTagIds.value = []
|
||||||
showAdvanced.value = false
|
showAdvanced.value = false
|
||||||
|
feedItems.value = []
|
||||||
|
loadHomepageFeed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 高级搜索 ──
|
// ── 高级搜索 ──
|
||||||
@@ -242,7 +242,6 @@ function handleAdvancedSearch(params: {
|
|||||||
tag_ids: string[]
|
tag_ids: string[]
|
||||||
retracted: string
|
retracted: string
|
||||||
negative_result: string
|
negative_result: string
|
||||||
precision_mode: string
|
|
||||||
sort: string
|
sort: string
|
||||||
}) {
|
}) {
|
||||||
showAdvanced.value = false
|
showAdvanced.value = false
|
||||||
@@ -256,7 +255,6 @@ function handleAdvancedSearch(params: {
|
|||||||
if (params.retracted) q.retracted = params.retracted
|
if (params.retracted) q.retracted = params.retracted
|
||||||
if (params.negative_result) q.negative = params.negative_result
|
if (params.negative_result) q.negative = params.negative_result
|
||||||
if (params.sort !== 'date') q.sort = params.sort
|
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 })
|
router.push({ name: 'public-search', query: q })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,17 +347,16 @@ async function loadMore() {
|
|||||||
// ── 从 URL 恢复状态 ──
|
// ── 从 URL 恢复状态 ──
|
||||||
function restoreFromUrl() {
|
function restoreFromUrl() {
|
||||||
const tag = route.query.tag as string
|
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
|
const q = route.query.q as string
|
||||||
if (tag) {
|
if (tag) {
|
||||||
const ids = tag.split(',')
|
const ids = tag.split(',')
|
||||||
selectedTagIds.value = ids
|
selectedTagIds.value = ids
|
||||||
searchParams.value.tag_ids = ids
|
searchParams.value.tag_ids = ids
|
||||||
}
|
}
|
||||||
if (date) {
|
if (dateFrom) searchParams.value.date_from = dateFrom
|
||||||
searchParams.value.date_from = date
|
if (dateTo) searchParams.value.date_to = dateTo
|
||||||
searchParams.value.date_to = date
|
|
||||||
}
|
|
||||||
if (q) {
|
if (q) {
|
||||||
searchParams.value.query = q
|
searchParams.value.query = q
|
||||||
localQuery.value = q
|
localQuery.value = q
|
||||||
@@ -376,7 +373,8 @@ watch(searchKey, () => {
|
|||||||
if (!_mounted.value) return
|
if (!_mounted.value) return
|
||||||
const query: Record<string, string> = {}
|
const query: Record<string, string> = {}
|
||||||
if (searchParams.value.tag_ids.length) query.tag = searchParams.value.tag_ids.join(',')
|
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
|
if (searchParams.value.query) query.q = searchParams.value.query
|
||||||
router.replace({ query })
|
router.replace({ query })
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user