Переглянути джерело

feat(content): S-cfc-392 content.article.search 知识分享页搜索交互完善 + C4-C6 前端契约测试

从实现分支 7145eb7c0 摘取(分支祖先链夹带 fix/mig-no-unique 他人提交,规避整分支合入):
- pages/article-center/index.vue:空态区分/搜索Tab互斥重置/onSearch重置/400ms防抖+搜索历史
- __tests__/api/api.test.js:getArticleList keyword 断言扩展
- __tests__/pages/article-center/articleSearch.contract.test.js:C4/C5/C6 前端契约测试
Agnes Agent 1 день тому
батько
коміт
3779dc800a

+ 17 - 0
cfc-frontend/__tests__/api/api.test.js

@@ -978,6 +978,23 @@ describe('articles API', () => {
     expect(lastRequest().url).toMatch(/\/api\/articles\/list$/)
   })
 
+  test('getArticleList carries keyword payload (C4 search contract)', async () => {
+    mockSuccess({ code: 200 })
+    await api.getArticleList({ page: 1, size: 10, keyword: '睡眠' })
+    const req = lastRequest()
+    expect(req.url).toMatch(/\/api\/articles\/list$/)
+    expect(req.method).toBe('POST')
+    expect(req.data.keyword).toBe('睡眠')
+    expect(req.data.page).toBe(1)
+    expect(req.data.size).toBe(10)
+  })
+
+  test('getArticleList passes keyword through raw (trim/cancel semantics live in the page)', async () => {
+    mockSuccess({ code: 200 })
+    await api.getArticleList({ page: 1, size: 10, keyword: '  ' })
+    expect(lastRequest().data.keyword).toBe('  ')
+  })
+
   test('getArticleDetail sends POST', async () => {
     mockSuccess({ code: 200 })
     await api.getArticleDetail({ id: 42 })

+ 368 - 0
cfc-frontend/__tests__/pages/article-center/articleSearch.contract.test.js

@@ -0,0 +1,368 @@
+/**
+ * 契约级单测(TDD-RED→GREEN)——能力键 content.article.search(前端部分 C4-C6)
+ *
+ * 溯源:
+ *   requirement: docs/需求分析/2026-11-14-小程序知识分享页搜索功能需求变更.md
+ *                (ARTICLE-SEARCH-2026-11-14-V1,契约 C4 触发语义 / C5 空态区分 / C6 标签互斥重置)
+ *   design:      docs/design/2026-11-14-article-center-search-design.md
+ *                (D3 前端交互完善 + D3a 输入防抖与搜索历史,交互参考 pages/shop/search/search.vue)
+ *   契约布局:___tests__/api/api.test.js(C4 请求体 keyword 透传断言)+ 本文件(页面方法/空态/互斥)
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend 无 vue-jest/jsdom 组件环境 —— 沿用「读取真实 SFC 源码 → 桩化 import → 求值
+ *   <script> 拿真实 export default → 驱动 data()/methods/computed 做行为断言,模板做结构断言」,
+ *   参考 __tests__/pages/article-center/articleTagFilter.contract.test.js。
+ *   本文件不修改 pages/、utils/ 任何业务代码。
+ *
+ * 小程序约束(AGENTS.md):禁可选链 ?.、禁 CSS Grid、禁 :key 表达式、禁 new Date(string)。
+ */
+
+import fs from 'fs'
+import path from 'path'
+import { BrandColors } from '@/utils/brand-colors.js'
+
+const PAGE_PATH = path.join(__dirname, '../../../pages/article-center/index.vue')
+
+/** 把 ES import 语句替换为从 globalThis.__articleCenterSearchStubs 取桩的声明(不解析模块,避免污染业务实现) */
+function stubImports(script, stubMap) {
+  return script.replace(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"];?/g, function (m, clause, id) {
+    var names = []
+    var c = clause.trim()
+    var braceIdx = c.indexOf('{')
+    if (braceIdx >= 0) {
+      var def = c.slice(0, braceIdx).replace(/,\s*$/, '').trim()
+      if (def) names.push(def)
+      var named = c.slice(braceIdx + 1, c.lastIndexOf('}'))
+      named.split(',').forEach(function (part) {
+        var p = part.trim()
+        if (!p) return
+        var parts = p.split(/\s+as\s+/)
+        var local = parts.length > 1 ? parts[1].trim() : parts[0].trim()
+        if (local) names.push(local)
+      })
+    } else if (c.indexOf('*') === 0) {
+      var nsMatch = c.match(/\*\s+as\s+(\w+)/)
+      if (nsMatch) names.push(nsMatch[1])
+    } else {
+      names.push(c)
+    }
+    return names.map(function (n) {
+      return 'var ' + n + ' = ((globalThis.__articleCenterSearchStubs || {})[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+/** 加载真实 SFC:返回 { component, template }(component = 真实 <script> 的 export default 对象) */
+function loadPage() {
+  var src = fs.readFileSync(PAGE_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!scriptMatch) throw new Error('无法在 article-center/index.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1], globalThis.__articleCenterSearchStubs || {})
+  script = script.replace(/export default\s*\{/, 'module.exports = {')
+  var mod = { exports: {} }
+  // eslint-disable-next-line no-new-func
+  var fn = new Function('module', 'exports', script)
+  fn(mod, mod.exports)
+  return { component: mod.exports, template: tmplMatch ? tmplMatch[1] : '' }
+}
+
+/** 依据真实组件的 data()/methods/computed 构造可驱动上下文(模拟 Vue 合并实例) */
+function instantiate(component) {
+  var vm = {}
+  var data = typeof component.data === 'function' ? component.data.call(vm) : (component.data || {})
+  Object.keys(data).forEach(function (k) { vm[k] = data[k] })
+  var methods = component.methods || {}
+  Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
+  var computed = component.computed || {}
+  Object.keys(computed).forEach(function (k) {
+    Object.defineProperty(vm, k, { get: computed[k], enumerable: true, configurable: true })
+  })
+  return vm
+}
+
+/** 每次调用时重新加载真实 SFC(读取当时的 __articleCenterSearchStubs),避免闭包捕获旧桩 */
+function getComponent() {
+  return loadPage().component
+}
+
+/** 刷新微任务队列:让 fire-and-forget 的 async 方法(loadArticles 等)结算完成 */
+function flushPromises() {
+  return new Promise(function (resolve) { setTimeout(resolve, 0) })
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具
+// ---------------------------------------------------------------------------
+
+var stubs
+
+beforeEach(function () {
+  global.uni._storage = {}
+  global.uni.showToast.mockReset()
+  global.uni.navigateTo.mockReset()
+  global.uni.request.mockReset()
+  stubs = {
+    getArticleList: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: { records: [], total: 0 } }) }),
+    getArticleTags: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getFeaturedArticles: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) })
+  }
+  globalThis.__articleCenterSearchStubs = {
+    '../../utils/brand-colors.js': { BrandColors: BrandColors },
+    '@/utils/api.js': {
+      getArticleList: stubs.getArticleList,
+      getArticleTags: stubs.getArticleTags,
+      getFeaturedArticles: stubs.getFeaturedArticles
+    },
+    '@/config.js': { config: { API_BASE_URL: 'http://127.0.0.1:9082' } },
+    '@/mixins/font-scale.js': { fontScaleMixin: { data: function () { return {} } } }
+  }
+})
+
+afterEach(function () {
+  delete globalThis.__articleCenterSearchStubs
+})
+
+// ---------------------------------------------------------------------------
+// 契约 C4:触发语义 —— onSearch 重置分页/清列表/noMore=false,请求体携带 trim keyword;
+// 空 keyword(取消)还原全量;400ms 防抖即时搜索;搜索历史本地存储(上限 10、去重、可清空)
+// ---------------------------------------------------------------------------
+
+describe('契约C4 index.vue:onSearch 触发语义 + 防抖 + 搜索历史', function () {
+  test('onSearch 同步重置 page=1/articles=[]/noMore=false,请求体携带 trim 后的 keyword', async function () {
+    stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [{ id: 1, title: '睡眠指南' }], total: 1 } })
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.page = 5
+    vm.articles = [{ id: 999 }]
+    vm.noMore = true
+    vm.searchKeyword = '  睡眠  '
+    vm.onSearch()
+    // 提交瞬间即重置(契约 C4:page=1、列表清空、noMore=false)
+    expect(vm.page).toBe(1)
+    expect(vm.articles).toEqual([])
+    expect(vm.noMore).toBe(false)
+    await flushPromises()
+    var params = stubs.getArticleList.mock.calls[0][0]
+    expect(params.keyword).toBe('睡眠')
+    expect(vm.articles).toEqual([{ id: 1, title: '睡眠指南' }])
+  })
+
+  test('onSearch 空 keyword(取消语义)→ 还原全量,请求体不带 keyword 且历史不落盘', async function () {
+    stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [{ id: 2, title: 'x' }], total: 1 } })
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.searchKeyword = '   '
+    vm.onSearch()
+    await flushPromises()
+    var params = stubs.getArticleList.mock.calls[0][0]
+    expect(params.keyword).toBeUndefined()
+    expect(params.tag).toBeUndefined()
+    expect(vm.articles).toEqual([{ id: 2, title: 'x' }])
+    expect(global.uni._storage['article_search_history']).toBeUndefined()
+  })
+
+  test('模板:搜索框保留 v-model/@confirm 并接入 @input="onSearchInput" 防抖', function () {
+    var template = loadPage().template
+    expect(template).toMatch(/v-model="searchKeyword"/)
+    expect(template).toMatch(/@confirm="onSearch"/)
+    expect(template).toMatch(/@input="onSearchInput"/)
+  })
+
+  test('onSearchInput 400ms 防抖即时搜索:只发一次且携带当前 keyword', function () {
+    jest.useFakeTimers()
+    try {
+      stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [], total: 0 } })
+      var vm = instantiate(getComponent())
+      vm.currentChildId = null
+      vm.searchKeyword = '睡眠'
+      vm.onSearchInput({ detail: { value: '睡眠' } })
+      expect(stubs.getArticleList).not.toHaveBeenCalled()
+      jest.advanceTimersByTime(399)
+      expect(stubs.getArticleList).not.toHaveBeenCalled()
+      jest.advanceTimersByTime(1)
+      expect(stubs.getArticleList).toHaveBeenCalledTimes(1)
+      expect(stubs.getArticleList.mock.calls[0][0].keyword).toBe('睡眠')
+    } finally {
+      jest.useRealTimers()
+    }
+  })
+
+  test('onSearchInput 连续输入只在防抖窗口结束后请求一次并取最新词', function () {
+    jest.useFakeTimers()
+    try {
+      var vm = instantiate(getComponent())
+      vm.currentChildId = null
+      vm.onSearchInput({ detail: { value: '睡' } })
+      vm.onSearchInput({ detail: { value: '睡眠' } })
+      vm.onSearchInput({ detail: { value: ' 睡眠法 ' } })
+      expect(stubs.getArticleList).not.toHaveBeenCalled()
+      jest.advanceTimersByTime(400)
+      expect(stubs.getArticleList).toHaveBeenCalledTimes(1)
+      expect(stubs.getArticleList.mock.calls[0][0].keyword).toBe('睡眠法')
+    } finally {
+      jest.useRealTimers()
+    }
+  })
+
+  test('onSearch 保存 trim 关键词到本地历史(article_search_history):去重、提到最前', function () {
+    var vm = instantiate(getComponent())
+    vm.searchKeyword = '睡眠'
+    vm.onSearch()
+    expect(vm.searchHistory).toEqual(['睡眠'])
+    expect(JSON.parse(global.uni._storage['article_search_history'])).toEqual(['睡眠'])
+
+    vm.searchKeyword = '成长'
+    vm.onSearch()
+    expect(vm.searchHistory).toEqual(['成长', '睡眠'])
+
+    // 去重:再次搜索相同关键词提到最前
+    vm.searchKeyword = '睡眠'
+    vm.onSearch()
+    expect(vm.searchHistory).toEqual(['睡眠', '成长'])
+    expect(vm.searchHistory.length).toBe(2)
+  })
+
+  test('搜索历史上限 10 条,超出丢弃最旧', function () {
+    var vm = instantiate(getComponent())
+    var seed = []
+    for (var i = 1; i <= 10; i++) seed.push('k' + i)
+    vm.searchHistory = seed.slice()
+    vm.searchKeyword = 'new'
+    vm.onSearch()
+    expect(vm.searchHistory.length).toBe(10)
+    expect(vm.searchHistory[0]).toBe('new')
+    expect(vm.searchHistory[9]).toBe('k9')
+    expect(vm.searchHistory.indexOf('k10')).toBe(-1)
+  })
+
+  test('onLoad 读取历史;非法 JSON 容错为空数组', async function () {
+    global.uni._storage['article_search_history'] = JSON.stringify(['睡眠', '成长'])
+    var vm = instantiate(getComponent())
+    var opts = getComponent()
+    await opts.onLoad.call(vm, {})
+    await flushPromises()
+    expect(vm.searchHistory).toEqual(['睡眠', '成长'])
+
+    global.uni._storage['article_search_history'] = '{bad json'
+    var vm2 = instantiate(getComponent())
+    await opts.onLoad.call(vm2, {})
+    expect(vm2.searchHistory).toEqual([])
+  })
+
+  test('clearSearchHistory 清空列表并删除本地存储', function () {
+    var vm = instantiate(getComponent())
+    vm.searchHistory = ['睡眠']
+    global.uni._storage['article_search_history'] = JSON.stringify(['睡眠'])
+    vm.clearSearchHistory()
+    expect(vm.searchHistory).toEqual([])
+    expect(global.uni._storage['article_search_history']).toBeUndefined()
+  })
+
+  test('模板:搜索历史条 v-for 渲染 searchHistory,点击历史项回填关键词', function () {
+    var template = loadPage().template
+    expect(template).toMatch(/v-for="\(item, idx\) in searchHistory"/)
+    expect(template).toMatch(/searchHistory.length/)
+    expect(template).toMatch(/clickSearchHistory\(item\)/)
+    expect(template).toMatch(/clearSearchHistory/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 契约 C5:空态区分 —— noMore && articles.length === 0 时,
+// searchKeyword 非空 →「未找到相关文章」,否则 →「暂无文章」
+// ---------------------------------------------------------------------------
+
+describe('契约C5 index.vue:搜索空态与「暂无文章」区分', function () {
+  test('模板:空态门禁 noMore && articles.length === 0,文案按 isSearchEmpty 区分', function () {
+    var template = loadPage().template
+    expect(template).toMatch(/v-else-if="noMore && articles\.length === 0"/)
+    expect(template).toMatch(/未找到相关文章/)
+    expect(template).toMatch(/暂无文章/)
+    expect(template).toMatch(/isSearchEmpty/)
+  })
+
+  test('搜索无命中(records=[] total=0)→ articles=[] 且 noMore=true,isSearchEmpty=true', async function () {
+    stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [], total: 0 } })
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.searchKeyword = '睡眠'
+    vm.onSearch()
+    await flushPromises()
+    expect(vm.articles).toEqual([])
+    expect(vm.noMore).toBe(true)
+    expect(vm.isSearchEmpty).toBe(true)
+  })
+
+  test('非搜索态空列表(无 keyword)→ isSearchEmpty=false(走「暂无文章」)', async function () {
+    stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [], total: 0 } })
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.searchKeyword = ''
+    vm.onSearch()
+    await flushPromises()
+    expect(vm.articles).toEqual([])
+    expect(vm.noMore).toBe(true)
+    expect(vm.isSearchEmpty).toBe(false)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 契约 C6:互斥重置 —— 进入搜索态清当前 Tab 回全量;切换 Tab 清搜索关键词与焦点
+// ---------------------------------------------------------------------------
+
+describe('契约C6 index.vue:搜索与标签筛选互斥重置', function () {
+  test('onTabChange 清空 searchKeyword/searchFocused,请求体带 tag 且不带 keyword', async function () {
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.searchKeyword = '睡眠'
+    vm.searchFocused = true
+    vm.onTabChange('减脂')
+    expect(vm.currentTab).toBe('减脂')
+    expect(vm.searchKeyword).toBe('')
+    expect(vm.searchFocused).toBe(false)
+    await flushPromises()
+    var params = stubs.getArticleList.mock.calls[0][0]
+    expect(params.tag).toBe('减脂')
+    expect(params.keyword).toBeUndefined()
+  })
+
+  test('focusSearch 进入搜索态:清当前 Tab 回全量并重拉全量列表(无 tag)', async function () {
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.currentTab = '减脂'
+    vm.focusSearch()
+    expect(vm.searchFocused).toBe(true)
+    expect(vm.currentTab).toBe('')
+    expect(vm.page).toBe(1)
+    expect(vm.articles).toEqual([])
+    await flushPromises()
+    var params = stubs.getArticleList.mock.calls[0][0]
+    expect(params.tag).toBeUndefined()
+  })
+
+  test('focusSearch 已在全量时不重复重拉列表', function () {
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.currentTab = ''
+    stubs.getArticleList.mockClear()
+    vm.focusSearch()
+    expect(vm.searchFocused).toBe(true)
+    expect(vm.currentTab).toBe('')
+    expect(stubs.getArticleList).not.toHaveBeenCalled()
+  })
+
+  test('标签态发起搜索(onSearch)→ currentTab 清空回全量,请求体无 tag 只带 keyword', async function () {
+    stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [{ id: 7, title: '睡眠' }], total: 1 } })
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.currentTab = '减脂'
+    vm.searchKeyword = '睡眠'
+    vm.onSearch()
+    expect(vm.currentTab).toBe('')
+    await flushPromises()
+    var params = stubs.getArticleList.mock.calls[0][0]
+    expect(params.keyword).toBe('睡眠')
+    expect(params.tag).toBeUndefined()
+  })
+})

+ 145 - 10
cfc-frontend/pages/article-center/index.vue

@@ -22,11 +22,28 @@
         placeholder="搜索文章..."
         confirm-type="search"
         @confirm="onSearch"
+        @input="onSearchInput"
         @blur="onSearchBlur"
       />
       <text class="ac-search-cancel" @click="searchKeyword = ''; searchFocused = false; onSearch()">取消</text>
     </view>
 
+    <!-- 搜索历史(聚焦且未输入时展示,上限 10 条可清空) -->
+    <view v-if="searchFocused && !searchKeyword && searchHistory.length" class="ac-search-history">
+      <view class="ac-history-header">
+        <text class="ac-history-title">搜索历史</text>
+        <text class="ac-history-clear" @click="clearSearchHistory">清除</text>
+      </view>
+      <view class="ac-history-tags">
+        <text
+          v-for="(item, idx) in searchHistory"
+          :key="idx"
+          class="ac-history-tag"
+          @click="clickSearchHistory(item)"
+        >{{ item }}</text>
+      </view>
+    </view>
+
     <!-- Tab 导航(药丸式) -->
     <view class="ac-tabs">
       <scroll-view scroll-x show-scrollbar="false" class="ac-tab-scroll">
@@ -67,10 +84,10 @@
         </view>
       </view>
 
-      <!-- 空状态 -->
-      <view v-else-if="articles.length === 0" class="ac-empty">
+      <!-- 空状态(搜索态无命中 → 未找到相关文章;非搜索态 → 暂无文章) -->
+      <view v-else-if="noMore && articles.length === 0" class="ac-empty">
         <image class="ac-empty-img" src="/static/empty-article.png" mode="aspectFit" lazy-load></image>
-        <text class="ac-empty-text">暂无文章</text>
+        <text class="ac-empty-text">{{ isSearchEmpty ? '未找到相关文章' : '暂无文章' }}</text>
       </view>
 
       <!-- 文章列表 -->
@@ -158,11 +175,18 @@ export default {
       noMore: false,
       searchKeyword: '',
       searchFocused: false,
+      searchHistory: [],
       isLoggedIn: false,
       recommendedArticles: [],
       recommendLoading: false
     }
   },
+  computed: {
+    // 搜索态判定:供空态文案区分「未找到相关文章 / 暂无文章」(契约 C5)
+    isSearchEmpty() {
+      return !!(this.searchKeyword && this.searchKeyword.trim())
+    }
+  },
   components: {
     ArticleBookshelf: () => require('@/pages/home-pages/components/ArticleBookshelf.vue')
   },
@@ -178,10 +202,17 @@ onLoad(options) {
     this.loadTags()
     this.loadArticles()
     this.loadRecommendedArticles()
+    this.loadSearchHistory()
 },
   onShow() {
     this.isLoggedIn = !!uni.getStorageSync('token')
   },
+  onUnload() {
+    if (this._searchTimer) {
+      clearTimeout(this._searchTimer)
+      this._searchTimer = null
+    }
+  },
   methods: {
     async loadTags() {
       try {
@@ -207,6 +238,8 @@ onLoad(options) {
         if (this.loadingMore || this.noMore) return
         this.loadingMore = true
       }
+      // 请求序号:新的搜索/刷新会使仍在途的旧响应作废,避免过期数据覆盖新结果
+      var seq = (this._seq = (this._seq || 0) + 1)
       try {
         var params = { page: this.page, size: this.size }
         if (this.currentChildId) {
@@ -219,6 +252,7 @@ onLoad(options) {
           params.keyword = this.searchKeyword.trim()
         }
         var res = await getArticleList(params)
+        if (seq !== this._seq) return
         if (res.code === 200 && res.data) {
           var list = res.data.records || []
           if (this.page === 1) {
@@ -230,10 +264,14 @@ onLoad(options) {
           this.noMore = this.articles.length >= this.total
         }
       } catch (e) {
-        uni.showToast({ title: '加载失败', icon: 'none' })
+        if (seq === this._seq) {
+          uni.showToast({ title: '加载失败', icon: 'none' })
+        }
       } finally {
-        this.loading = false
-        this.loadingMore = false
+        if (seq === this._seq) {
+          this.loading = false
+          this.loadingMore = false
+        }
       }
     },
     onTabChange(code) {
@@ -241,9 +279,7 @@ onLoad(options) {
       this.currentTab = code
       this.searchKeyword = ''
       this.searchFocused = false
-      this.page = 1
-      this.articles = []
-      this.noMore = false
+      this.resetList()
       this.loadArticles()
     },
     async loadRecommendedArticles() {
@@ -316,12 +352,78 @@ onLoad(options) {
     },
     focusSearch() {
       this.searchFocused = true
+      // 进入搜索态:清当前 Tab 回全量(搜索与标签筛选互斥,契约 C6)
+      if (this.currentTab !== '') {
+        this.currentTab = ''
+        this.resetList()
+        this.loadArticles()
+      }
     },
     onSearch() {
+      this.doSearch(true)
+    },
+    doSearch(saveHistory) {
+      var keyword = (this.searchKeyword || '').trim()
+      // 进入搜索态:清当前 Tab 回全量(互斥重置,契约 C6)
+      if (this.currentTab !== '') {
+        this.currentTab = ''
+      }
+      this.resetList()
+      if (saveHistory && keyword) {
+        this.saveSearchHistory(keyword)
+      }
+      this.loadArticles()
+    },
+    onSearchInput(e) {
+      var value = e && e.detail ? e.detail.value : this.searchKeyword
+      this.searchKeyword = value
+      var self = this
+      if (this._searchTimer) clearTimeout(this._searchTimer)
+      // 400ms 防抖即时搜索(避免逐字请求,契约 D3a)
+      this._searchTimer = setTimeout(function () {
+        self._searchTimer = null
+        self.doSearch(false)
+      }, 400)
+    },
+    loadSearchHistory() {
+      var raw = uni.getStorageSync('article_search_history')
+      if (!raw) {
+        this.searchHistory = []
+        return
+      }
+      try {
+        var arr = JSON.parse(raw)
+        this.searchHistory = Array.isArray(arr) ? arr : []
+      } catch (e) {
+        this.searchHistory = []
+      }
+    },
+    saveSearchHistory(keyword) {
+      if (!keyword) return
+      var list = this.searchHistory.filter(function (h) { return h !== keyword })
+      list.unshift(keyword)
+      if (list.length > 10) {
+        list = list.slice(0, 10)
+      }
+      this.searchHistory = list
+      uni.setStorageSync('article_search_history', JSON.stringify(list))
+    },
+    clearSearchHistory() {
+      this.searchHistory = []
+      uni.removeStorageSync('article_search_history')
+    },
+    clickSearchHistory(keyword) {
+      this.searchKeyword = keyword
+      this.onSearch()
+    },
+    resetList() {
       this.page = 1
       this.articles = []
       this.noMore = false
-      this.loadArticles()
+      // 作废旧请求:让新搜索/刷新取代仍在途的响应,避免旧数据覆盖新结果
+      this._seq = (this._seq || 0) + 1
+      this.loading = false
+      this.loadingMore = false
     },
     onSearchBlur() {
       if (!this.searchKeyword) {
@@ -426,6 +528,39 @@ onLoad(options) {
   flex-shrink: 0;
 }
 
+/* ---- 搜索历史 ---- */
+.ac-search-history {
+  padding: 16rpx 28rpx;
+  background: $app-surface;
+  border-bottom: 1rpx solid $facade-fill;
+}
+.ac-history-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.ac-history-title {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+.ac-history-clear {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-ghost;
+}
+.ac-history-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.ac-history-tag {
+  padding: 6rpx 20rpx;
+  border-radius: 20rpx;
+  background: $facade-fill;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+}
+
 /* ---- Tab 药丸导航 ---- */
 .ac-tabs {
   padding: 16rpx 28rpx;