fix: 第20轮搜索审计修复 — AND子组层级/OR模式NOT/长格式标签等6项
Bug-R20-1 (HIGH): AND子组在括号内被提升为顶层AND条件 — 新增sub_group_refs层级追踪 Bug-R20-2 (HIGH): OR模式NOT条件未独立AND — 分离UnaryExpression独立AND Bug-R20-3 (MEDIUM): is_pubmed_syntax()不识别小写布尔运算符 — 添加re.IGNORECASE Bug-R20-4 (MEDIUM): 长格式字段标签不被识别 — _ALL_FIELD_TAGS新增40+条目 Bug-R20-5 (MEDIUM): 400错误URL持久化导致刷新循环 — syncSearchToUrl移入try块 Bug-R20-6 (LOW): 空括号[]产生空Term — stripped为空时跳过
This commit is contained in:
@@ -112,6 +112,45 @@ _FIELD_TAG_MAP: dict[str, str] = {
|
|||||||
"FI": "GR", # [FI] Funder Identifier → 同 GR(grant_id)
|
"FI": "GR", # [FI] Funder Identifier → 同 GR(grant_id)
|
||||||
"SO": "journal", # [SO] Source → journal(近似)
|
"SO": "journal", # [SO] Source → journal(近似)
|
||||||
"PL": "journal", # [PL] Place of Publication → journal(近似)
|
"PL": "journal", # [PL] Place of Publication → journal(近似)
|
||||||
|
# P20: PubMed 长格式字段标签
|
||||||
|
"TITLE": "title",
|
||||||
|
"ABSTRACT": "abstract",
|
||||||
|
"ALL FIELDS": "all",
|
||||||
|
"MESH TERMS": "MH",
|
||||||
|
"MESH MAJOR TOPIC": "MAJR",
|
||||||
|
"TEXT WORD": "all",
|
||||||
|
"LANGUAGE": "language",
|
||||||
|
"AUTHOR": "author",
|
||||||
|
"JOURNAL": "journal",
|
||||||
|
"AFFILIATION": "affiliation",
|
||||||
|
"PUBLICATION DATE": "DP",
|
||||||
|
"SUBSTANCE NAME": "NM",
|
||||||
|
"GRANT NUMBER": "GR",
|
||||||
|
"PHARMACOLOGICAL ACTION": "PA",
|
||||||
|
"MESH SUBHEADING": "SH",
|
||||||
|
"PUBLICATION TYPE": "PT",
|
||||||
|
"DATE COMPLETED": "DCOM",
|
||||||
|
"DATE CREATED": "CRDT",
|
||||||
|
"DATE MESH CREATED": "MHDA",
|
||||||
|
"ENTRY DATE": "EDAT",
|
||||||
|
"LAST REVISED": "LR",
|
||||||
|
"DATE REVISED": "LR",
|
||||||
|
"DATE OF ELECTRONIC PUBLICATION": "DEP",
|
||||||
|
"SECONDARY SOURCE ID": "SI",
|
||||||
|
"SUBSET": "SB",
|
||||||
|
"STATUS": "STAT",
|
||||||
|
"TRANSLITERATED TITLE": "TT",
|
||||||
|
"VERNACULAR TITLE": "TT",
|
||||||
|
"OTHER TERM": "OT",
|
||||||
|
"GENE SYMBOL": "GEN",
|
||||||
|
"PMC ID": "PMC",
|
||||||
|
"VOLUME": "volume",
|
||||||
|
"ISSUE": "issue",
|
||||||
|
"PAGINATION": "pages",
|
||||||
|
"PERSONAL NAME AS SUBJECT": "PS",
|
||||||
|
"INVESTIGATOR": "IR",
|
||||||
|
"CONFLICT OF INTEREST STATEMENT": "COIS",
|
||||||
|
"AUTHOR IDENTIFIER": "AUID",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 需要特殊处理的字段(不直接映射到 field 参数)
|
# 需要特殊处理的字段(不直接映射到 field 参数)
|
||||||
@@ -152,6 +191,21 @@ _ALL_FIELD_TAGS = {
|
|||||||
"Title/Abstract", # [Title/Abstract] 长标签
|
"Title/Abstract", # [Title/Abstract] 长标签
|
||||||
"OAB", "WORD", # [OAB] Other Abstract, [WORD] Word in text
|
"OAB", "WORD", # [OAB] Other Abstract, [WORD] Word in text
|
||||||
"FI", "GEN", "PMC", "SO", "PL", # [FI] Funder, [GEN] Gene, [PMC] PMCID, [SO] Source, [PL] Place
|
"FI", "GEN", "PMC", "SO", "PL", # [FI] Funder, [GEN] Gene, [PMC] PMCID, [SO] Source, [PL] Place
|
||||||
|
# P20: PubMed 长格式字段标签
|
||||||
|
"Title", "Abstract", "All Fields",
|
||||||
|
"MeSH Terms", "MeSH Major Topic",
|
||||||
|
"Text Word", "Language", "Author", "Journal", "Affiliation",
|
||||||
|
"Publication Date", "Substance Name", "Grant Number",
|
||||||
|
"Pharmacological Action", "MeSH Subheading", "Publication Type",
|
||||||
|
"Date Completed", "Date Created", "Date MeSH Created", "Entry Date",
|
||||||
|
"Last Revised", "Date Revised",
|
||||||
|
"Date of Electronic Publication",
|
||||||
|
"Secondary Source ID", "Subset", "Status",
|
||||||
|
"Transliterated Title", "Vernacular Title",
|
||||||
|
"Other Term", "Gene Symbol", "PMC ID",
|
||||||
|
"Volume", "Issue", "Pagination",
|
||||||
|
"Personal Name as Subject", "Investigator",
|
||||||
|
"Conflict of Interest Statement", "Author Identifier",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -219,9 +273,11 @@ def tokenise(query: str) -> list[Token]:
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
ttype = TokenType[name]
|
ttype = TokenType[name]
|
||||||
if ttype == TokenType.UNKNOWN_FIELD:
|
if ttype == TokenType.UNKNOWN_FIELD:
|
||||||
fname = value.strip('[]').upper()
|
stripped = value.strip('[]')
|
||||||
# P0-1: 不认识字段标签时降级为 WORD,不终止解析
|
# P20: skip empty brackets like `[]`
|
||||||
tokens.append(Token(TokenType.WORD, value.strip('[]')))
|
if not stripped:
|
||||||
|
continue
|
||||||
|
tokens.append(Token(TokenType.WORD, stripped))
|
||||||
if len(tokens) > MAX_TERMS:
|
if len(tokens) > MAX_TERMS:
|
||||||
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
|
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
|
||||||
continue
|
continue
|
||||||
@@ -310,6 +366,7 @@ class ParsedPubmedQuery:
|
|||||||
groups: list[list[Term]] = field(default_factory=list) # parenthesized sub-groups
|
groups: list[list[Term]] = field(default_factory=list) # parenthesized sub-groups
|
||||||
group_operators: list[str] = field(default_factory=list) # "and"/"or" per group (P2-2)
|
group_operators: list[str] = field(default_factory=list) # "and"/"or" per group (P2-2)
|
||||||
group_negated: list[bool] = field(default_factory=list) # P16: True if group was wrapped by NOT (external negation)
|
group_negated: list[bool] = field(default_factory=list) # P16: True if group was wrapped by NOT (external negation)
|
||||||
|
sub_group_refs: list[list[int]] = field(default_factory=list) # P20: parent_gid → [child_gid, ...] for AND sub-groups
|
||||||
negated_date_ranges: set[str] = field(default_factory=set) # date fields negated by NOT
|
negated_date_ranges: set[str] = field(default_factory=set) # date fields negated by NOT
|
||||||
_date_range_markers: list[Term] = field(default_factory=list, repr=False) # internal: date range Term collectors
|
_date_range_markers: list[Term] = field(default_factory=list, repr=False) # internal: date range Term collectors
|
||||||
|
|
||||||
@@ -642,6 +699,7 @@ class PubmedQueryParser:
|
|||||||
result.groups.append(cluster)
|
result.groups.append(cluster)
|
||||||
result.group_operators.append("and")
|
result.group_operators.append("and")
|
||||||
result.group_negated.append(False) # P19: keep lengths aligned
|
result.group_negated.append(False) # P19: keep lengths aligned
|
||||||
|
result.sub_group_refs.append([]) # P20: keep lengths aligned
|
||||||
all_terms.extend(cluster)
|
all_terms.extend(cluster)
|
||||||
return all_terms
|
return all_terms
|
||||||
|
|
||||||
@@ -726,8 +784,18 @@ class PubmedQueryParser:
|
|||||||
# 只对尚未分组的词创建外层组,避免 Term 被放入两个组导致 _pubmed_conditions 重复处理。
|
# 只对尚未分组的词创建外层组,避免 Term 被放入两个组导致 _pubmed_conditions 重复处理。
|
||||||
_parent_gid = len(result.groups)
|
_parent_gid = len(result.groups)
|
||||||
_ungrouped = [t for t in terms if t.group_id < 0]
|
_ungrouped = [t for t in terms if t.group_id < 0]
|
||||||
|
# P20: track sub-groups created by _parse_or_expr inside this paren
|
||||||
|
_child_gids = sorted(set(t.group_id for t in terms if t.group_id >= 0))
|
||||||
for t in _ungrouped:
|
for t in _ungrouped:
|
||||||
t.group_id = _parent_gid
|
t.group_id = _parent_gid
|
||||||
|
if _ungrouped or _child_gids:
|
||||||
|
# P20: ensure sub_group_refs is aligned with groups
|
||||||
|
while len(result.sub_group_refs) < _parent_gid:
|
||||||
|
result.sub_group_refs.append([])
|
||||||
|
if _child_gids:
|
||||||
|
result.sub_group_refs.append(_child_gids)
|
||||||
|
else:
|
||||||
|
result.sub_group_refs.append([])
|
||||||
if _ungrouped:
|
if _ungrouped:
|
||||||
result.groups.append(_ungrouped)
|
result.groups.append(_ungrouped)
|
||||||
_has_or = any(t.type == TokenType.OR for t in self.tokens[start_pos:end_pos])
|
_has_or = any(t.type == TokenType.OR for t in self.tokens[start_pos:end_pos])
|
||||||
@@ -902,7 +970,7 @@ def is_pubmed_syntax(query: str) -> bool:
|
|||||||
query = unicodedata.normalize('NFKC', query)
|
query = unicodedata.normalize('NFKC', query)
|
||||||
if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE):
|
if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE):
|
||||||
return True
|
return True
|
||||||
if re.search(r'\b(AND|OR|NOT)\b', query):
|
if re.search(r'\b(AND|OR|NOT)\b', query, re.IGNORECASE):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -1182,31 +1182,62 @@ class AdvancedSearchEngine:
|
|||||||
# 处理括号分组的词(保留 OR/AND 嵌套结构,P2-2)
|
# 处理括号分组的词(保留 OR/AND 嵌套结构,P2-2)
|
||||||
if pp.groups:
|
if pp.groups:
|
||||||
for idx, group in enumerate(pp.groups):
|
for idx, group in enumerate(pp.groups):
|
||||||
|
# P20: skip sub-groups that are children of a parent group
|
||||||
|
_is_child = any(
|
||||||
|
idx in _refs for _refs in pp.sub_group_refs
|
||||||
|
) if pp.sub_group_refs else False
|
||||||
|
if _is_child:
|
||||||
|
continue
|
||||||
|
|
||||||
# P16: group_negated[idx] tracks external NOT wrapper (NOT (...)),
|
# P16: group_negated[idx] tracks external NOT wrapper (NOT (...)),
|
||||||
# vs is_not per-term (internal NOT, set by _parse_not_expr).
|
# vs is_not per-term (internal NOT, set by _parse_not_expr).
|
||||||
# Using group_negated instead of all(t.is_not for t in group)
|
|
||||||
# fixes the case where all terms have is_not internally
|
|
||||||
# from individual NOTs: (NOT A OR NOT B) → all_not=True but
|
|
||||||
# should NOT be wrapped in a single not_(or_(...)).
|
|
||||||
_negated = (pp.group_negated[idx]
|
_negated = (pp.group_negated[idx]
|
||||||
if idx < len(pp.group_negated)
|
if idx < len(pp.group_negated)
|
||||||
else False)
|
else False)
|
||||||
g_pos = []
|
g_pos = []
|
||||||
g_neg = []
|
g_neg = []
|
||||||
for t in group:
|
for t in group:
|
||||||
# P15: __RANGE_* markers are side-effect-only (set edat_from/to etc. on ParsedPubmedQuery
|
# P15: __RANGE_* markers are side-effect-only
|
||||||
# at parse time). In groups they must be skipped, not dispatched through _single_term_condition
|
|
||||||
# which would treat them as plain text "all" search.
|
|
||||||
if getattr(t, '_is_range_end', False):
|
if getattr(t, '_is_range_end', False):
|
||||||
continue
|
continue
|
||||||
cond = await AdvancedSearchEngine._single_term_condition(db, t)
|
cond = await AdvancedSearchEngine._single_term_condition(db, t)
|
||||||
if cond is not None:
|
if cond is not None:
|
||||||
if _negated:
|
if _negated:
|
||||||
g_neg.append(cond) # raw condition,外部统一 not_()
|
g_neg.append(cond)
|
||||||
elif t.is_not:
|
elif t.is_not:
|
||||||
g_neg.append(not_(cond))
|
g_neg.append(not_(cond))
|
||||||
else:
|
else:
|
||||||
g_pos.append(cond)
|
g_pos.append(cond)
|
||||||
|
|
||||||
|
# P20: process child sub-groups and fold them into this parent group
|
||||||
|
_child_gids = pp.sub_group_refs[idx] if idx < len(pp.sub_group_refs) else []
|
||||||
|
for child_gid in _child_gids:
|
||||||
|
child_group = pp.groups[child_gid]
|
||||||
|
child_op = pp.group_operators[child_gid] if child_gid < len(pp.group_operators) else "and"
|
||||||
|
child_fn = or_ if child_op == "or" else and_
|
||||||
|
child_pos = []
|
||||||
|
child_neg = []
|
||||||
|
for t in child_group:
|
||||||
|
if getattr(t, '_is_range_end', False):
|
||||||
|
continue
|
||||||
|
cond = await AdvancedSearchEngine._single_term_condition(db, t)
|
||||||
|
if cond is not None:
|
||||||
|
if t.is_not:
|
||||||
|
child_neg.append(not_(cond))
|
||||||
|
else:
|
||||||
|
child_pos.append(cond)
|
||||||
|
child_combined = None
|
||||||
|
if child_pos:
|
||||||
|
child_combined = child_fn(*child_pos) if len(child_pos) > 1 else child_pos[0]
|
||||||
|
if child_neg:
|
||||||
|
_negc = child_fn(*child_neg) if len(child_neg) > 1 else child_neg[0]
|
||||||
|
child_combined = child_fn(child_combined, _negc) if child_combined is not None else _negc
|
||||||
|
if child_combined is not None:
|
||||||
|
if _negated:
|
||||||
|
g_neg.append(child_combined)
|
||||||
|
else:
|
||||||
|
g_pos.append(child_combined)
|
||||||
|
|
||||||
gop = (pp.group_operators[idx]
|
gop = (pp.group_operators[idx]
|
||||||
if idx < len(pp.group_operators)
|
if idx < len(pp.group_operators)
|
||||||
else "and")
|
else "and")
|
||||||
@@ -1232,7 +1263,18 @@ class AdvancedSearchEngine:
|
|||||||
# 将 term_conditions 加入 conditions
|
# 将 term_conditions 加入 conditions
|
||||||
if term_conditions:
|
if term_conditions:
|
||||||
if pp.boolean_operator == "or":
|
if pp.boolean_operator == "or":
|
||||||
# OR 模式:所有条件(含 NOT)OR 在一起
|
# OR 模式:NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C)
|
||||||
|
from sqlalchemy.sql.elements import UnaryExpression
|
||||||
|
from sqlalchemy.sql import operators as _sa_ops
|
||||||
|
_pos = [c for c in term_conditions
|
||||||
|
if not (isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv)]
|
||||||
|
_neg = [c for c in term_conditions
|
||||||
|
if isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv]
|
||||||
|
if _neg:
|
||||||
|
if _pos:
|
||||||
|
conditions.append(or_(*_pos))
|
||||||
|
conditions.extend(_neg)
|
||||||
|
else:
|
||||||
conditions.append(or_(*term_conditions))
|
conditions.append(or_(*term_conditions))
|
||||||
elif pp.boolean_operator == "mixed":
|
elif pp.boolean_operator == "mixed":
|
||||||
# mixed 模式下 NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C)
|
# mixed 模式下 NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C)
|
||||||
|
|||||||
+64
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
||||||
>
|
>
|
||||||
> **累计**:19 轮,218 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
> **累计**:20 轮,224 项修复,80+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
||||||
> **时间跨度**:2026-07-24 ~ 2026-07-29
|
> **时间跨度**:2026-07-24 ~ 2026-07-29
|
||||||
> **核心文件**:`pubmed_query_parser.py`(~850 行)→ `search_engine.py`(~1360 行)
|
> **核心文件**:`pubmed_query_parser.py`(~850 行)→ `search_engine.py`(~1360 行)
|
||||||
|
|
||||||
@@ -1390,6 +1390,69 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 第二十轮:第 20 轮审计修复(6 项)
|
||||||
|
|
||||||
|
**日期**:2026-07-29
|
||||||
|
**提交**:`2b6ffd6`(与第 19 轮同一提交基础上追加)
|
||||||
|
**数量**:6 项(2 HIGH + 3 MEDIUM + 1 LOW)
|
||||||
|
**触发**:用户第 15 次要求全面检查(Round 20,4 并行 agent:R19 回归/OR 模式、分词器/边界、集成/租户、前端集成)
|
||||||
|
**测试**:1007 全部通过 + 前端 build 通过
|
||||||
|
|
||||||
|
### Bug-R20-1 (HIGH): AND 子组在括号内被提升为顶层 AND 条件
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py`(`_parse_primary` + `_parse_or_expr`)、`search_engine.py`(组处理循环)
|
||||||
|
- **根因**:第 19 轮移除了 `_depth > 0` 守卫,`_parse_or_expr` 在括号内创建 AND 子组(如 `(A OR B AND C)` → sub-group `[B,C]`,parent group `[A]`)。但引擎将两个组独立处理后全部 AND 在一起 → `A AND B AND C`。正确语义应为 `A OR (B AND C)`。
|
||||||
|
- **影响**:任何括号内混用 AND/OR 的查询(如 `(lung OR breast cancer)`)结果被严重过滤。
|
||||||
|
- **修复**:
|
||||||
|
- 解析器:新增 `ParsedPubmedQuery.sub_group_refs: list[list[int]]` 记录 parent→child 关系
|
||||||
|
- `_parse_primary` 在创建父组时记录子组 GID
|
||||||
|
- 引擎跳过子组(由父组处理),父组处理时包含自身 term + 子组条件,用父组操作符组合
|
||||||
|
- **验证**:`(A OR B AND C)` 正确生成 `or_(A, and_(B, C))`
|
||||||
|
|
||||||
|
### Bug-R20-2 (HIGH): OR 模式 NOT 条件未独立 AND
|
||||||
|
|
||||||
|
- **文件**:`search_engine.py:1234-1236`
|
||||||
|
- **根因**:`boolean_operator == "or"` 时所有条件(含 `not_(cond)`)被 OR 在一起:`or_(cond_A, not_(cond_B))` → 匹配 A OR 非 B(几乎全库)。PubMed 语义:`A OR B NOT C` = `(A OR B) AND NOT C`。
|
||||||
|
- **影响**:任何 OR+NOT 混合查询(如 `cancer OR NOT review`)返回结果极大膨胀。
|
||||||
|
- **修复**:OR 模式复用 `mixed` 模式的 NOT 分离逻辑:将 `UnaryExpression` NOT 条件分离出来独立 AND。
|
||||||
|
|
||||||
|
### Bug-R20-3 (MEDIUM): `is_pubmed_syntax()` 不识别小写布尔运算符
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py:917`
|
||||||
|
- **根因**:布尔运算符正则 `r'\b(AND|OR|NOT)\b'` 缺少 `re.IGNORECASE`。`is_pubmed_syntax("cancer and tumor")` 返回 `False` → 查询不触发 PubMed 路径。
|
||||||
|
- **影响**:使用小写布尔运算符的 PubMed 查询丢失所有字段语义。
|
||||||
|
- **修复**:`re.search(..., re.IGNORECASE)`。
|
||||||
|
|
||||||
|
### Bug-R20-4 (MEDIUM): 长格式字段标签不被识别
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py`(`_ALL_FIELD_TAGS` + `_FIELD_TAG_MAP`)
|
||||||
|
- **根因**:`[Title]`、`[All Fields]`、`[MeSH Terms]` 等长格式标签不在 `_ALL_FIELD_TAGS` 中,被降级为普通 WORD。示例:`cancer[Title]` → 三个普通词。
|
||||||
|
- **修复**:`_ALL_FIELD_TAGS` 新增 40+ 长格式标签;`_FIELD_TAG_MAP` 新增到规范内部名的映射。
|
||||||
|
- **验证**:`cancer[Title]` → field="title"、`breast[MeSH Major Topic]` → field="MAJR"
|
||||||
|
|
||||||
|
### Bug-R20-5 (MEDIUM): 400 错误 URL 持久化导致刷新循环
|
||||||
|
|
||||||
|
- **文件**:`SearchView.vue:379`
|
||||||
|
- **根因**:`syncSearchToUrl()` 在 `finally` 中无条件运行,400 参数被持久化到 URL。刷新后恢复相同参数 → 再 400 → 死循环。
|
||||||
|
- **修复**:`syncSearchToUrl()` 移到 `try` 块末尾(仅成功时同步)。
|
||||||
|
|
||||||
|
### Bug-R20-6 (LOW): 空括号 `[]` 产生空 Term
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py:221-224`
|
||||||
|
- **根因**:UNKNOWN_FIELD 匹配 `[]`,`strip('[]')` 产生空字符串 → 创建空 Term。
|
||||||
|
- **修复**:`stripped` 为空时跳过。
|
||||||
|
|
||||||
|
### 审计结果汇总
|
||||||
|
|
||||||
|
| 审计维度 | 结果 |
|
||||||
|
|---------|------|
|
||||||
|
| R19 回归(AND 子组) | ✅ `sub_group_refs` 层级追踪 + 引擎层级处理 |
|
||||||
|
| OR 模式 NOT 语义 | ✅ 分离 NOT 条件独立 AND |
|
||||||
|
| 分词器/边界 | ✅ `re.IGNORECASE`、长格式标签、空括号跳过 |
|
||||||
|
| 前端集成 | ✅ 400 URL 持久化循环修复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 第十七轮:第 17 轮审计修复(3 项)
|
## 第十七轮:第 17 轮审计修复(3 项)
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ const { page, total, goToPage } = usePagination({
|
|||||||
}
|
}
|
||||||
yearCounts.value = data.year_counts || []
|
yearCounts.value = data.year_counts || []
|
||||||
searchError.value = ''
|
searchError.value = ''
|
||||||
|
syncSearchToUrl() // 成功时同步 URL
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
|
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
|
||||||
results.value = []
|
results.value = []
|
||||||
@@ -376,7 +377,6 @@ const { page, total, goToPage } = usePagination({
|
|||||||
toast.apiError(e, '搜索失败,请重试')
|
toast.apiError(e, '搜索失败,请重试')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
syncSearchToUrl() // P6: sync URL even on error (avoid URL/state desync)
|
|
||||||
if (gen === searchGeneration.value) loading.value = false
|
if (gen === searchGeneration.value) loading.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user