---
date: 2026-05-13
type: frontend-bug
module: admin-product-line-list
priority: high
notify: ["@mmg"]
status: pending
---

# 主题列表「点搜索第一回必然不好使，第二回才好使」— 路由守卫自己取消了自己发的请求

## 现象

管理后台「产品管理 → 主题列表」(`/product/line`)，切换筛选条件（主题类型下拉或关键词）后点「搜索」按钮：

- **第 1 次点搜索：列表无任何变化**（看起来没反应）
- **第 2 次点同一个搜索：列表才刷新**

DevTools Network 面板可见：每次点搜索都发出 **2 个 URL 完全相同的请求**（如 `list?keyword=&productType=CORE&page=1&pageSize=20`）：
- 请求 1：`status = (canceled)`，`size = 0.0 kB`，4ms
- 请求 2：`status = 200`，`size = 1.2 kB`，61ms

100% 必现。

## 根因（纯前端，后端无关）

文件：`hl-ui/src/views/product/line/index.vue` + 通用的 `hl-ui/src/router/index.js` + `hl-ui/src/utils/request.js`

`handleSearchSubmit` 里干了两件事（顺序）：

```js
const handleSearchSubmit = debounce(() => {
  syncToUrl()        // 1. 调 router.replace({ query }) 把筛选条件同步到 URL
  resetAndLoad()     // 2. 调 fetchList()，axios 发请求
}, 300)
```

而 `router/index.js` 的 `beforeEach` 守卫里每次路由跳转前会调 `clearAllPendingRequests()` 取消所有正在飞的 axios 请求（项目自带的请求清理机制，用来防止路由切换后还在跑的旧请求污染新页面）。

**时序冲突**：

1. `syncToUrl()` 触发 `router.replace`，但 `beforeEach` 是**异步**触发的（微任务/下一拍）
2. `resetAndLoad()` **同步**立即调 axios 发出请求 A，加入 `pendingRequests` Map
3. 这一拍微任务跑到 `beforeEach` → `clearAllPendingRequests()` → **把刚发出的请求 A 取消了**（canceled）
4. 但 `watch(route.query)` 或 `useSearchParams` 的 query 同步逻辑监听到 URL 变化，**又补发了请求 B**
5. 请求 B 这次没有再触发路由跳转，beforeEach 不再跑，请求 B 顺利 200 → 列表刷新

**所以用户看到的现象是：点 1 次搜索 → 2 个请求 → 第 1 个被自家路由守卫掐了 → 第 2 个才成功 → 因为防抖 300ms 的存在，用户感觉是"点了没反应"，下一次手动再点才好使。**

实际上每次都是「第 1 个被取消、第 2 个成功」；但视觉上数据更新有延迟 + canceled 的请求让 loading 状态错乱，**用户主观感受就是「第一次不生效」**。

## 修复方案（前端，2 选 1）

### 改法 A（推荐，最小改动）— `handleSearchSubmit` 里只发请求，不动 URL

URL 同步不是必须每次都做，可以从"搜索时同步"改成"组件卸载/页面切换时同步"，或者干脆只用 `replaceState` 不触发 vue-router（避免触发 beforeEach）：

```js
const handleSearchSubmit = debounce(() => {
  // syncToUrl()     // ❌ 删掉，或下面替换成静默 replaceState
  // 静默版（如果还想保留 URL 可分享的能力）：
  //   const url = new URL(window.location.href)
  //   Object.entries(searchForm).forEach(([k,v]) => v ? url.searchParams.set(k,v) : url.searchParams.delete(k))
  //   window.history.replaceState({}, '', url)   // 绕过 vue-router，不触发 beforeEach
  resetAndLoad()
}, 300)
```

`window.history.replaceState` 不会触发 vue-router 的 `beforeEach`，自然不会触发 `clearAllPendingRequests`。

### 改法 B — `syncToUrl()` 后等 router.replace 完成的 Promise 再 `resetAndLoad()`

```js
const handleSearchSubmit = debounce(async () => {
  await syncToUrl()       // 让 router.replace 走完 beforeEach（此时 pendingRequests 是空的，cancel 啥都没影响）
  resetAndLoad()          // 这次发出的请求不会被自己取消
}, 300)
```

需要 `syncToUrl()` 返回 `router.replace(...)` 的 Promise。

### 改法 C（不推荐）— 让 `clearAllPendingRequests` 不在 beforeEach 跑

这是改通用 request 拦截器，会影响所有页面的路由切换清理逻辑，风险大，**不推荐**。

## 推荐方案

**改法 A**：`handleSearchSubmit` 内不用 vue-router 改 URL，直接 `window.history.replaceState` 静默同步。改动局限在 `product/line/index.vue` 一个文件，零副作用。

## 顺手检查

同一个团队规律：项目中其他列表页凡是用了「点搜索 → syncToUrl → fetchList」的双步模式都可能踩这个坑。建议 grep `syncToUrl` 找出所有调用点，统一改成方案 A 的写法。常见嫌疑列表：

- `views/order/list`（订单列表）
- `views/order-v2/list`（订单管理 v2）
- `views/product-v2/list`（产品 v2 列表）
- `views/customer/list`（客户管理）
- `views/agency/list`（公司管理）

如果上述页面也有「点搜索第一次不灵」的反馈，根因 100% 一样。

## 验证清单（前端改完自测）

- [ ] 进入「主题列表」，切换主题类型下拉 → 点搜索 1 次 → 列表立即刷新（Network 只有 1 个请求且 200）
- [ ] 输入关键词 → 点搜索 1 次 → 列表立即刷新
- [ ] 点「重置」按钮 → 列表恢复全量
- [ ] URL query 仍能正确反映当前筛选条件（用于分享/刷新保持状态）
- [ ] 浏览器前进/后退键能恢复对应筛选条件
- [ ] 顺手验证：上面"顺手检查"列出的 5 个嫌疑列表页是否同样存在双请求，如有一并修

## 后端无需任何改动

后端 `GET /admin/product-line/list` 接口本身正常，2 个请求带的参数也都正确，问题完全在前端搜索按钮的实现 + 路由守卫的清理时序冲突。这条 changelog 只为通知前端修。

@mmg 看下 `hl-ui/src/views/product/line/index.vue` 的 `handleSearchSubmit`，按改法 A 改即可，预计 P1 — 影响所有列表页搜索体验。
