Przeglądaj źródła

feat(web): 列表页进入编辑返回保持查询条件/页码/排序(以商品管理为例)

- Layout.vue 用 keep-alive 白名单缓存列表页组件,返回时免重建
- 新增 utils/listState.js 与 mixins/ListStateMixin.js:筛选/页码/排序同步到 URL query,
  刷新、深链场景同样可还原;keep-alive 返回时 activated 刷新列表数据
- ProductManage 接入 Mixin,进入编辑页携带列表状态参数,保存/取消返回原样恢复;
  补上模板引用但缺失的 resetFilters 实现
- ProductEdit 保存后返回列表时原样带回列表状态参数(去掉 id)
- 新增 listState 工具函数的 jest 用例
E2E Test Bot 6 dni temu
rodzic
commit
74c24f9287

+ 86 - 0
cfc-web/src/__tests__/utils/listState.test.js

@@ -0,0 +1,86 @@
+import { buildListQuery, parseListQuery, sameQuery, DEFAULT_PAGE_SIZE } from '@/utils/listState'
+
+describe('listState utils', () => {
+  describe('buildListQuery', () => {
+    it('默认无变化时返回空对象', () => {
+      const q = buildListQuery(
+        { keyword: '', status: '', productType: '' },
+        { page: 1, size: DEFAULT_PAGE_SIZE, total: 0 },
+        []
+      )
+      expect(q).toEqual({})
+    })
+
+    it('非默认值被写进 query', () => {
+      const q = buildListQuery(
+        { keyword: '儿童', status: 'on_shelf', productType: '' },
+        { page: 3, size: DEFAULT_PAGE_SIZE, total: 42 },
+        [{ column: 'name', order: 'asc' }]
+      )
+      expect(q).toEqual({
+        keyword: '儿童',
+        status: 'on_shelf',
+        page: '3',
+        sort: 'name:asc'
+      })
+    })
+
+    it('size 非 20 时写入 size', () => {
+      const q = buildListQuery(
+        {},
+        { page: 1, size: 50, total: 100 },
+        []
+      )
+      expect(q.size).toBe('50')
+    })
+
+    it('sort 序列化与反序列化一致', () => {
+      const sortState = [{ column: 'createdAt', order: 'desc' }, { column: 'name', order: 'asc' }]
+      const q = buildListQuery({}, { page: 1, size: 20, total: 0 }, sortState)
+      const parsed = parseListQuery(q, { filters: {}, size: 20 })
+      expect(parsed.sort).toEqual(sortState)
+    })
+  })
+
+  describe('parseListQuery', () => {
+    it('从查询字符串还原筛选/页码/排序', () => {
+      const q = { keyword: '测试', status: 'approved', page: '2', size: '50', sort: 'stock:desc' }
+      const r = parseListQuery(q, { filters: { keyword: '', status: '', productType: '' }, size: 20 })
+      expect(r.filters.keyword).toBe('测试')
+      expect(r.filters.status).toBe('approved')
+      expect(r.page).toBe(2)
+      expect(r.size).toBe(50)
+      expect(r.sort).toEqual([{ column: 'stock', order: 'desc' }])
+    })
+
+    it('缺失字段回退到默认值', () => {
+      const q = { keyword: 'baby' }
+      const r = parseListQuery(q, { filters: { keyword: '', status: '', productType: '' }, size: 10 })
+      expect(r.filters.keyword).toBe('baby')
+      expect(r.filters.status).toBe('')
+      expect(r.page).toBe(1)
+      expect(r.size).toBe(10)
+      expect(r.sort).toEqual([])
+    })
+
+    it('非法 size 回退到默认值', () => {
+      const r = parseListQuery({ size: '99' }, { filters: {}, size: 20 })
+      expect(r.size).toBe(20)
+    })
+  })
+
+  describe('sameQuery', () => {
+    it('相同键值返回 true', () => {
+      expect(sameQuery({ a: '1', b: '2' }, { b: '2', a: '1' })).toBe(true)
+    })
+    it('缺少一个 key 返回 false', () => {
+      expect(sameQuery({ a: '1' }, { a: '1', b: '2' })).toBe(false)
+    })
+    it('值不同返回 false', () => {
+      expect(sameQuery({ page: '3' }, { page: '2' })).toBe(false)
+    })
+    it('键集合不同视为不等价', () => {
+      expect(sameQuery({}, { page: undefined })).toBe(false)
+    })
+  })
+})

+ 94 - 0
cfc-web/src/mixins/ListStateMixin.js

@@ -0,0 +1,94 @@
+/**
+ * 列表页状态保持 Mixin
+ *
+ * 解决的问题:从列表页(如商品管理)进入编辑/详情页,返回时查询条件、页码、勾选丢失。
+ *
+ * 实现(三层配合,顺序执行):
+ *   1. Layout.vue 的 <keep-alive :include="cachedPages"> 缓存组件实例——
+ *      记忆DOM态(勾选、滚动、弹窗),返回零延迟;页面把 name 加进数组即可接入。
+ *   2. URL query 同步——restoreStateFromUrl() 在 created 时把 query 还原成
+ *      filters/page/size/sort;loadList() 后调用 syncStateToUrl() 把状态写回 URL,
+ *      保证刷新、深链、keep-alive 兜不住时同样能还原。
+ *   3. activated() 刷新——keep-alive 缓存命中返回时自动重新请求列表
+ *      (状态全保留,仅刷新数据),让编辑页刚保存的修改立即可见。
+ *
+ * 页面接入方式(以 ProductManage 为例):
+ *   mixins: [SortMixin, ListStateMixin]
+ *   - data 含 filters / pagination(筛选键名即 URL 参数名);进入编辑页传 id:
+ *       this.$router.push({ path: '/product-edit', query: { ...this.$route.query, id: row.id } })
+ *   - mounted() { this.loadList() }            // 首次加载
+ *   - loadList() 末尾加 this.syncStateToUrl() // 状态同步到 URL
+ *   - 「重置」按钮:this.resetListState(); this.loadList()
+ *   编辑页保存后返回:push('/product-manage', { query: { ...编辑页收到的列表参数 } })
+ */
+import { buildListQuery, parseListQuery, sameQuery } from '@/utils/listState'
+
+export default {
+  created() {
+    // 快照页面初始筛选/分页默认值(供「重置」和 URL 还原兜底,仅执行一次)
+    this._defaultFilters = {}
+    if (this.filters) {
+      Object.keys(this.filters).forEach(k => {
+        this._defaultFilters[k] = this.filters[k]
+      })
+    }
+    this._defaultPageSize = this.pagination ? this.pagination.size : undefined
+    this._listStateActivated = false
+    this.restoreStateFromUrl()
+  },
+  activated() {
+    // 缓存命中从其他页面返回时:刷新数据(筛选/页码/勾选保留在内存)
+    if (this._listStateActivated) {
+      this.reloadKeepingState()
+    }
+    this._listStateActivated = true
+    // el-table 在 display:none 缓存期间高度可能失真,激活时重算布局
+    this.$nextTick(() => {
+      if (this.$refs.table && typeof this.$refs.table.doLayout === 'function') {
+        this.$refs.table.doLayout()
+      }
+    })
+  },
+  methods: {
+    /** 从 URL query 还原筛选/分页/排序(首次进入、刷新、深链时有效) */
+    restoreStateFromUrl() {
+      if (!this.$route || !this.$route.query) return
+      const restored = parseListQuery(this.$route.query, {
+        filters: this._defaultFilters,
+        size: this._defaultPageSize
+      })
+      if (this.filters) Object.assign(this.filters, restored.filters)
+      if (this.pagination) {
+        this.pagination.page = restored.page
+        this.pagination.size = restored.size
+      }
+      if (this.sortState) this.sortState = restored.sort
+    },
+    /** 把当前列表状态同步到 URL query(无变化时不做无谓的导航) */
+    syncStateToUrl() {
+      if (!this.$route || !this.$router) return
+      const next = buildListQuery(this.filters, this.pagination, this.sortState)
+      if (sameQuery(next, this.$route.query)) return
+      this.$router.replace({ path: this.$route.path, query: next }).catch(() => {})
+    },
+    /** 重置筛选并回到第 1 页、清空排序(配合页面「重置」按钮使用) */
+    resetListState() {
+      if (this.filters) Object.assign(this.filters, this._defaultFilters || {})
+      if (this.pagination) {
+        this.pagination.page = 1
+        if (this._defaultPageSize) this.pagination.size = this._defaultPageSize
+      }
+      if (this.sortState) this.sortState = []
+    },
+    /** keep-alive 返回时刷新列表数据(替换 SortMixin 同款方法探测约定) */
+    reloadKeepingState() {
+      const reloaders = ['loadList', 'loadData', 'loadTasks', 'loadPlans']
+      for (let i = 0; i < reloaders.length; i++) {
+        if (typeof this[reloaders[i]] === 'function') {
+          this[reloaders[i]]()
+          break
+        }
+      }
+    }
+  }
+}

+ 92 - 0
cfc-web/src/utils/listState.js

@@ -0,0 +1,92 @@
+/**
+ * 列表页状态保持工具(筛选条件 / 排序 / 页码)
+ *
+ * 解决的问题:列表页 → 编辑/详情页 → 返回,查询条件和页码丢失。
+ * 两个层次互相配合:
+ *   1. URL query 承载列表状态:进入编辑页时把状态参数带过去,返回时原样带回,
+ *      天然支持刷新、分享、深链(Layout.vue 的 keep-alive 兜不住时也能还原);
+ *   2. Layout.vue 的 <keep-alive :include="cachedPages">:直接保住组件实例,
+ *      表格勾选、滚动位置、弹窗可见性等 DOM 态一起保留,返回零延迟。
+ *
+ * 页面接入约定(以 ProductManage 为例,见 ListStateMixin):
+ *   - data 中必须有 filters / pagination 两个字段(filter 键名即 URL 参数名)
+ *   - 混入 SortMixin 时有 sortState 字段,排序状态也会自动同步
+ */
+
+// 列表页默认分页大小(与各页 el-pagination 默认 page-size 保持一致)
+export const DEFAULT_PAGE_SIZE = 20
+// 允许的每页条数(写进 URL 前校验,避免非法值)
+export const ALLOWED_PAGE_SIZES = [10, 20, 50]
+
+/**
+ * 把列表状态序列化为 URL query 对象(空值 / 默认值省略,保持 URL 简洁)
+ * @param {object} filters   筛选对象,如 { keyword: 'xx', status: 'on_shelf' }
+ * @param {object} pagination 分页对象,如 { page: 3, size: 20 }
+ * @param {Array}  sortState   SortMixin 的排序状态,如 [{ column: 'name', order: 'asc' }]
+ * @returns {object} 可传给 this.$router.replace / push 的 query
+ */
+export function buildListQuery(filters, pagination, sortState) {
+  const query = {}
+  if (filters) {
+    Object.keys(filters).forEach(k => {
+      const v = filters[k]
+      if (v !== undefined && v !== null && v !== '') query[k] = String(v)
+    })
+  }
+  if (pagination) {
+    if (pagination.page && pagination.page > 1) query.page = String(pagination.page)
+    if (pagination.size && pagination.size !== DEFAULT_PAGE_SIZE) query.size = String(pagination.size)
+  }
+  if (sortState && sortState.length > 0) {
+    query.sort = sortState.map(s => s.column + ':' + s.order).join(',')
+  }
+  return query
+}
+
+/**
+ * 从 URL query 还原列表状态(缺失字段回退到传入的默认值)
+ * @param {object} query     this.$route.query
+ * @param {object} defaults  { filters: {...}, size: 20 },filters 每键的兜底值取自页面初始值
+ * @returns {{ filters: object, page: number, size: number, sort: Array }}
+ */
+export function parseListQuery(query, defaults) {
+  const d = defaults || {}
+  const baseFilters = d.filters || {}
+  const filters = {}
+  Object.keys(baseFilters).forEach(k => {
+    const v = query[k]
+    filters[k] = (v !== undefined && v !== null && v !== '') ? v : baseFilters[k]
+  })
+
+  const sort = []
+  if (query.sort) {
+    query.sort.split(',').forEach(pair => {
+      const i = pair.indexOf(':')
+      if (i > 0) {
+        const column = pair.slice(0, i)
+        const order = pair.slice(i + 1)
+        if (order === 'asc' || order === 'desc') sort.push({ column, order })
+      }
+    })
+  }
+
+  const page = parseInt(query.page, 10)
+  const size = parseInt(query.size, 10)
+  return {
+    filters,
+    page: page > 0 ? page : 1,
+    size: ALLOWED_PAGE_SIZES.indexOf(size) > -1 ? size : (d.size || DEFAULT_PAGE_SIZE),
+    sort
+  }
+}
+
+/**
+ * 判断两个 query 对象是否等价(键集合与键值都一致),
+ * 用于避免筛选无变化时产生无意义的 router.replace 导航。
+ */
+export function sameQuery(a, b) {
+  const keysA = Object.keys(a)
+  const keysB = Object.keys(b)
+  if (keysA.length !== keysB.length) return false
+  return keysA.every(k => String(a[k] || '') === String(b[k] || ''))
+}

+ 7 - 1
cfc-web/src/views/Layout.vue

@@ -150,7 +150,10 @@
       </el-dialog>
       <el-main>
         <div class="main-wrapper">
-          <router-view />
+          <!-- 白名单缓存列表页组件:进入编辑/详情返回时保留筛选条件、页码、勾选等状态(见 ListStateMixin) -->
+          <keep-alive :include="cachedPages">
+            <router-view />
+          </keep-alive>
         </div>
       </el-main>
     </el-container>
@@ -356,6 +359,9 @@ export default {
               ]},
           ]},
       ],
+      // 需要保持列表状态的页面组件 name(进入编辑/详情返回时保留筛选、页码、勾选)。
+      // 接入方式:把组件 name 加进数组,并在该页面混入 ListStateMixin(见 src/mixins/ListStateMixin.js)
+      cachedPages: ['ProductManage'],
       menuLoadFailed: false,
       profileDialogVisible: false,
       passwordDialogVisible: false,

+ 5 - 2
cfc-web/src/views/admin/ProductEdit.vue

@@ -1165,8 +1165,11 @@ async loadGiftRule(productId) {
               }
             }
             this.$message.success(this.isEdit ? '保存成功' : '创建成功')
-            const page = this.$route.query.page || 1
-            this.$router.push(`/product-manage?page=${page}`)
+            // 返回列表:原样带回进入编辑时的列表状态参数(筛选/页码/排序,去掉 id),
+            // 配合列表页 <keep-alive> + URL query,避免返回后查询条件和页码丢失
+            const listQuery = Object.assign({}, this.$route.query)
+            delete listQuery.id
+            this.$router.push({ path: '/product-manage', query: listQuery })
           } else {
             this.$message.error(res.message || '操作失败')
           }

+ 14 - 2
cfc-web/src/views/admin/ProductManage.vue

@@ -317,10 +317,11 @@
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
+import ListStateMixin from '@/mixins/ListStateMixin'
 import { getProductList, reviewProduct, shelveProduct, batchShelveProduct, copyProduct } from '@/api/admin'
 
 export default {
-  mixins: [SortMixin],
+  mixins: [SortMixin, ListStateMixin],
   name: 'ProductManage',
   data() {
     return {
@@ -372,6 +373,8 @@ export default {
         this.$message.error('加载失败')
       } finally {
         this.loading = false
+        // 把当前筛选/页码/排序同步到 URL query,刷新或从编辑页返回时能自动还原
+        this.syncStateToUrl()
       }
     },
     handleSizeChange(size) {
@@ -379,8 +382,17 @@ export default {
       this.pagination.page = 1
       this.loadList()
     },
+    // 重置筛选:清空筛选条件、回到第 1 页、清空排序(URL query 由 loadList 一并清理)
+    resetFilters() {
+      this.resetListState()
+      this.loadList()
+    },
     handleEdit(row) {
-      this.$router.push('/product-edit?id=' + row.id + '&page=' + this.pagination.page)
+      // 跳转编辑页时带上当前列表状态参数(筛选/页码/排序),返回后原样还原
+      this.$router.push({
+        path: '/product-edit',
+        query: Object.assign({}, this.$route.query, { id: row.id })
+      })
     },
     handlePageChange(page) {
       this.pagination.page = page