|
@@ -1,48 +1,86 @@
|
|
|
/**
|
|
/**
|
|
|
- * 列表页状态保持 Mixin
|
|
|
|
|
|
|
+ * 列表页状态保持 Mixin v2(安全模式)
|
|
|
*
|
|
*
|
|
|
- * 解决的问题:从列表页(如商品管理)进入编辑/详情页,返回时查询条件、页码、勾选丢失。
|
|
|
|
|
|
|
+ * 核心能力(零风险):
|
|
|
|
|
+ * 1. keep-alive 缓存 + activated() 自动刷新(探测 loader)——解决「编辑返回状态丢失」的核心痛点
|
|
|
|
|
+ * 2. 可选:URL query 同步/还原 —— 仅当页面显式声明 listStateMap 时启用,防止形态差异导致误伤
|
|
|
*
|
|
*
|
|
|
- * 实现(三层配合,顺序执行):
|
|
|
|
|
- * 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 缓存命中返回时自动重新请求列表
|
|
|
|
|
- * (状态全保留,仅刷新数据),让编辑页刚保存的修改立即可见。
|
|
|
|
|
|
|
+ * 页面接入(两种模式任选其一):
|
|
|
|
|
+ * A. 仅保状态(默认):mixins: [ListStateMixin],其它不用动。
|
|
|
|
|
+ * B. 额外要 URL 深链:在组件选项声明 listStateMap(见下),并确保 loader 名称在 LOADER_CANDIDATES 中。
|
|
|
*
|
|
*
|
|
|
- * 页面接入方式(以 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: { ...编辑页收到的列表参数 } })
|
|
|
|
|
|
|
+ * listStateMap 说明:
|
|
|
|
|
+ * - filters: 'searchForm' | ['keyword','type'] | null 筛选字段来源
|
|
|
|
|
+ * - pagination: { page: 'pagination.current', size: 'pagination.size' } | { page: 'page', size: 'size' } | { page: 'currentPage', size: 'pageSize' } | { page: 'pageNum', size: 'pageSize' } | { page: 'filter.page', size: 'filter.size' } // 点路径
|
|
|
|
|
+ * - loader: 'loadChildren' | 'loadData' | 'loadActivities' | 'loadList' | ... 若不在候选名单需显式指定
|
|
|
|
|
+ * - combined?: true // 分页键在 filters 对象内(如 filter.page, query.page),此时分页路径相对 filters 对象
|
|
|
|
|
+ *
|
|
|
|
|
+ * 常见预设(可直接用):
|
|
|
|
|
+ * // searchForm + pagination.current/size
|
|
|
|
|
+ * listStateMap: { filters: 'searchForm', pagination: { page: 'pagination.current', size: 'pagination.size' }, loader: 'loadXxx' }
|
|
|
|
|
+ *
|
|
|
|
|
+ * // filter 内含 page/size + 独立 keyword
|
|
|
|
|
+ * listStateMap: { filters: 'filter', pagination: { page: 'page', size: 'size' }, loader: 'loadXxx', combined: true }
|
|
|
|
|
+ *
|
|
|
|
|
+ * // query 对象包含 page/pageSize
|
|
|
|
|
+ * listStateMap: { filters: 'query', pagination: { page: 'page', size: 'pageSize' }, loader: 'loadXxx', combined: true }
|
|
|
|
|
+ *
|
|
|
|
|
+ * // 仅独立 page/size(无筛选对象)
|
|
|
|
|
+ * listStateMap: { filters: null, pagination: { page: 'page', size: 'size' }, loader: 'loadXxx' }
|
|
|
|
|
+ *
|
|
|
|
|
+ * 重置:页面「重置」按钮处调用 this.resetListState()(需已声明 map)。
|
|
|
*/
|
|
*/
|
|
|
-import { buildListQuery, parseListQuery, sameQuery } from '@/utils/listState'
|
|
|
|
|
|
|
+import { sameQuery, ALLOWED_PAGE_SIZES } from '@/utils/listState'
|
|
|
|
|
+
|
|
|
|
|
+// 常见加载方法名(自动探测顺序;也可在 map 中显式指定 loader)
|
|
|
|
|
+const LOADER_CANDIDATES = [
|
|
|
|
|
+ 'loadList', 'loadData', 'loadActivities', 'loadChildren', 'loadFamilies', 'loadUsers',
|
|
|
|
|
+ 'loadRewards', 'loadWishes', 'loadPointsLogs', 'loadTasks', 'loadChildTasks', 'loadTemplates',
|
|
|
|
|
+ 'fetchLogs', 'loadPackages', 'loadSummary', 'loadLogs', 'loadProducts', 'loadOrders',
|
|
|
|
|
+ 'loadAssignments', 'loadTemplates'
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+// 常见筛选对象名(仅用于自动 loader 探测,不做 URL sync)
|
|
|
|
|
+const FILTER_OBJECT_CANDIDATES = [
|
|
|
|
|
+ 'filters', 'searchForm', 'filter', 'query', 'listQuery', 'filterForm', 'recordFilter', 'searchParams'
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+// 常见分页字段名(仅用于自动探测辅助)
|
|
|
|
|
+const PAGE_KEYS = ['page', 'current', 'pageNo', 'pageNum', 'currentPage']
|
|
|
|
|
+const SIZE_KEYS = ['size', 'pageSize', 'pageLimit', 'limit']
|
|
|
|
|
+
|
|
|
|
|
+function getByPath(obj, path) {
|
|
|
|
|
+ if (!obj || !path) return undefined
|
|
|
|
|
+ return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setByPath(obj, path, val) {
|
|
|
|
|
+ if (!obj || !path) return
|
|
|
|
|
+ const keys = path.split('.')
|
|
|
|
|
+ const last = keys.pop()
|
|
|
|
|
+ const target = keys.reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj)
|
|
|
|
|
+ if (target) target[last] = val
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
export default {
|
|
export default {
|
|
|
created() {
|
|
created() {
|
|
|
- // 快照页面初始筛选/分页默认值(供「重置」和 URL 还原兜底,仅执行一次)
|
|
|
|
|
- this._defaultFilters = {}
|
|
|
|
|
- if (this.filters) {
|
|
|
|
|
- Object.keys(this.filters).forEach(k => {
|
|
|
|
|
- this._defaultFilters[k] = this.filters[k]
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ // 1) 读取显式 map(优先)或为 undefined(不启用 URL 同步)
|
|
|
|
|
+ this._lsMap = this.$options.listStateMap || null
|
|
|
|
|
+
|
|
|
|
|
+ // 2) 自动探测 loader(用于 activated 刷新)——不依赖 map,保证最小功能可用
|
|
|
|
|
+ this._lsLoaderName = LOADER_CANDIDATES.find(k => typeof this[k] === 'function')
|
|
|
|
|
+
|
|
|
|
|
+ // 3) 若有 map,做快照(用于 reset)并包装 loader 以便 URL 同步
|
|
|
|
|
+ if (this._lsMap) {
|
|
|
|
|
+ this._lsSnapshot()
|
|
|
|
|
+ this._lsWrapLoader()
|
|
|
|
|
+ this.restoreStateFromUrl()
|
|
|
}
|
|
}
|
|
|
- this._defaultPageSize = this.pagination ? this.pagination.size : undefined
|
|
|
|
|
- this._listStateActivated = false
|
|
|
|
|
- this.restoreStateFromUrl()
|
|
|
|
|
},
|
|
},
|
|
|
activated() {
|
|
activated() {
|
|
|
- // 缓存命中从其他页面返回时:刷新数据(筛选/页码/勾选保留在内存)
|
|
|
|
|
- if (this._listStateActivated) {
|
|
|
|
|
- this.reloadKeepingState()
|
|
|
|
|
- }
|
|
|
|
|
- this._listStateActivated = true
|
|
|
|
|
- // el-table 在 display:none 缓存期间高度可能失真,激活时重算布局
|
|
|
|
|
|
|
+ // keep-alive 返回时刷新列表(无 map 也生效,只依赖 loader)
|
|
|
|
|
+ if (this._lsActivated) this.reloadKeepingState()
|
|
|
|
|
+ this._lsActivated = true
|
|
|
this.$nextTick(() => {
|
|
this.$nextTick(() => {
|
|
|
if (this.$refs.table && typeof this.$refs.table.doLayout === 'function') {
|
|
if (this.$refs.table && typeof this.$refs.table.doLayout === 'function') {
|
|
|
this.$refs.table.doLayout()
|
|
this.$refs.table.doLayout()
|
|
@@ -50,45 +88,175 @@ export default {
|
|
|
})
|
|
})
|
|
|
},
|
|
},
|
|
|
methods: {
|
|
methods: {
|
|
|
- /** 从 URL query 还原筛选/分页/排序(首次进入、刷新、深链时有效) */
|
|
|
|
|
|
|
+ // === URL 同步/还原(仅在 _lsMap 存在时有效) ===
|
|
|
restoreStateFromUrl() {
|
|
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._lsMap) return
|
|
|
|
|
+ const q = this.$route && this.$route.query ? this.$route.query : {}
|
|
|
|
|
+ const m = this._lsMap
|
|
|
|
|
+ // filters
|
|
|
|
|
+ if (m.filters) {
|
|
|
|
|
+ const src = typeof m.filters === 'string' ? this[m.filters] : null
|
|
|
|
|
+ if (src) {
|
|
|
|
|
+ if (Array.isArray(m.filters)) {
|
|
|
|
|
+ m.filters.forEach(k => { if (q[k] !== undefined) this[k] = q[k] })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Object.keys(src).forEach(k => {
|
|
|
|
|
+ if (k === 'sort' || k === 'total') return
|
|
|
|
|
+ if (PAGE_KEYS.includes(k) || SIZE_KEYS.includes(k)) return
|
|
|
|
|
+ if (q[k] !== undefined && q[k] !== '' && q[k] !== null) {
|
|
|
|
|
+ src[k] = q[k]
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // pagination
|
|
|
|
|
+ if (m.pagination) {
|
|
|
|
|
+ const pageVal = q.page ? parseInt(q.page, 10) : NaN
|
|
|
|
|
+ const sizeVal = q.size ? parseInt(q.size, 10) : NaN
|
|
|
|
|
+ if (m.combined && m.filters && typeof m.filters === 'string') {
|
|
|
|
|
+ const filterObj = this[m.filters]
|
|
|
|
|
+ if (m.pagination.page) setByPath(filterObj, m.pagination.page, pageVal)
|
|
|
|
|
+ if (m.pagination.size && !isNaN(sizeVal) && ALLOWED_PAGE_SIZES.includes(sizeVal)) setByPath(filterObj, m.pagination.size, sizeVal)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (m.pagination.page && !isNaN(pageVal) && pageVal > 0) setByPath(this, m.pagination.page, pageVal)
|
|
|
|
|
+ if (m.pagination.size && !isNaN(sizeVal) && ALLOWED_PAGE_SIZES.includes(sizeVal)) setByPath(this, m.pagination.size, sizeVal)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // sort
|
|
|
|
|
+ if (this.sortState && q.sort) {
|
|
|
|
|
+ const s = []
|
|
|
|
|
+ q.sort.split(',').forEach(pair => {
|
|
|
|
|
+ const i = pair.indexOf(':')
|
|
|
|
|
+ if (i > 0) {
|
|
|
|
|
+ const col = pair.slice(0, i), ord = pair.slice(i + 1)
|
|
|
|
|
+ if (ord === 'asc' || ord === 'desc') s.push({ column: col, order: ord })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ this.sortState = s
|
|
|
}
|
|
}
|
|
|
- if (this.sortState) this.sortState = restored.sort
|
|
|
|
|
},
|
|
},
|
|
|
- /** 把当前列表状态同步到 URL query(无变化时不做无谓的导航) */
|
|
|
|
|
syncStateToUrl() {
|
|
syncStateToUrl() {
|
|
|
- if (!this.$route || !this.$router) return
|
|
|
|
|
- const next = buildListQuery(this.filters, this.pagination, this.sortState)
|
|
|
|
|
- if (sameQuery(next, this.$route.query)) return
|
|
|
|
|
|
|
+ if (!this._lsMap || !this.$route || !this.$router) return
|
|
|
|
|
+ const m = this._lsMap
|
|
|
|
|
+ const next = {}
|
|
|
|
|
+
|
|
|
|
|
+ // filters
|
|
|
|
|
+ if (m.filters) {
|
|
|
|
|
+ const src = typeof m.filters === 'string' ? this[m.filters] : null
|
|
|
|
|
+ if (src) {
|
|
|
|
|
+ if (Array.isArray(m.filters)) {
|
|
|
|
|
+ m.filters.forEach(k => {
|
|
|
|
|
+ const v = this[k]
|
|
|
|
|
+ if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') next[k] = String(v)
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Object.keys(src).forEach(k => {
|
|
|
|
|
+ if (k === 'sort' || k === 'total') return
|
|
|
|
|
+ if (PAGE_KEYS.includes(k) || SIZE_KEYS.includes(k)) return
|
|
|
|
|
+ const v = src[k]
|
|
|
|
|
+ if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') next[k] = String(v)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // pagination
|
|
|
|
|
+ if (m.pagination) {
|
|
|
|
|
+ let pageVal, sizeVal
|
|
|
|
|
+ if (m.combined && m.filters && typeof m.filters === 'string') {
|
|
|
|
|
+ const filterObj = this[m.filters]
|
|
|
|
|
+ pageVal = getByPath(filterObj, m.pagination.page)
|
|
|
|
|
+ sizeVal = getByPath(filterObj, m.pagination.size)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ pageVal = getByPath(this, m.pagination.page)
|
|
|
|
|
+ sizeVal = getByPath(this, m.pagination.size)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pageVal && pageVal > 1) next.page = String(pageVal)
|
|
|
|
|
+ if (sizeVal && sizeVal !== (this._lsDefaultSize || 20)) next.size = String(sizeVal)
|
|
|
|
|
+ }
|
|
|
|
|
+ // sort
|
|
|
|
|
+ if (this.sortState && this.sortState.length) {
|
|
|
|
|
+ next.sort = this.sortState.map(s => s.column + ':' + s.order).join(',')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const curQuery = this.$route && this.$route.query ? this.$route.query : {}
|
|
|
|
|
+ if (sameQuery(next, curQuery)) return
|
|
|
this.$router.replace({ path: this.$route.path, query: next }).catch(() => {})
|
|
this.$router.replace({ path: this.$route.path, query: next }).catch(() => {})
|
|
|
},
|
|
},
|
|
|
- /** 重置筛选并回到第 1 页、清空排序(配合页面「重置」按钮使用) */
|
|
|
|
|
resetListState() {
|
|
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._lsMap) return
|
|
|
|
|
+ const m = this._lsMap
|
|
|
|
|
+ if (m.filters) {
|
|
|
|
|
+ const src = typeof m.filters === 'string' ? this[m.filters] : null
|
|
|
|
|
+ if (src && this._lsDefaultFilters) {
|
|
|
|
|
+ if (Array.isArray(m.filters)) {
|
|
|
|
|
+ m.filters.forEach(k => { if (this._lsDefaultLoose && this._lsDefaultLoose[k] !== undefined) this[k] = this._lsDefaultLoose[k] })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Object.keys(src).forEach(k => { if (this._lsDefaultFilters[k] !== undefined) src[k] = this._lsDefaultFilters[k] })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (m.pagination) {
|
|
|
|
|
+ if (m.combined && m.filters && typeof m.filters === 'string') {
|
|
|
|
|
+ const filterObj = this[m.filters]
|
|
|
|
|
+ if (m.pagination.page) setByPath(filterObj, m.pagination.page, 1)
|
|
|
|
|
+ if (m.pagination.size && this._lsDefaultSize) setByPath(filterObj, m.pagination.size, this._lsDefaultSize)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (m.pagination.page) setByPath(this, m.pagination.page, 1)
|
|
|
|
|
+ if (m.pagination.size && this._lsDefaultSize) setByPath(this, m.pagination.size, this._lsDefaultSize)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
if (this.sortState) this.sortState = []
|
|
if (this.sortState) this.sortState = []
|
|
|
},
|
|
},
|
|
|
- /** keep-alive 返回时刷新列表数据(替换 SortMixin 同款方法探测约定) */
|
|
|
|
|
|
|
+ // === activated 刷新(无 map 也生效) ===
|
|
|
reloadKeepingState() {
|
|
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
|
|
|
|
|
|
|
+ const name = this._lsLoaderName
|
|
|
|
|
+ if (name && typeof this[name] === 'function') {
|
|
|
|
|
+ this[name]()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 兜底:遍历候选列表
|
|
|
|
|
+ for (const k of LOADER_CANDIDATES) {
|
|
|
|
|
+ if (typeof this[k] === 'function') { this[k](); break }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // 内部方法
|
|
|
|
|
+ _lsSnapshot() {
|
|
|
|
|
+ const m = this._lsMap
|
|
|
|
|
+ if (!m) return
|
|
|
|
|
+ if (m.filters) {
|
|
|
|
|
+ const src = typeof m.filters === 'string' ? this[m.filters] : null
|
|
|
|
|
+ if (src) {
|
|
|
|
|
+ if (Array.isArray(m.filters)) {
|
|
|
|
|
+ this._lsDefaultLoose = {}
|
|
|
|
|
+ m.filters.forEach(k => { this._lsDefaultLoose[k] = this[k] })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this._lsDefaultFilters = {}
|
|
|
|
|
+ Object.keys(src).forEach(k => { this._lsDefaultFilters[k] = src[k] })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (m.pagination && m.pagination.size) {
|
|
|
|
|
+ if (m.combined && m.filters && typeof m.filters === 'string') {
|
|
|
|
|
+ const filterObj = this[m.filters]
|
|
|
|
|
+ this._lsDefaultSize = getByPath(filterObj, m.pagination.size)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this._lsDefaultSize = getByPath(this, m.pagination.size)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ _lsWrapLoader() {
|
|
|
|
|
+ const name = this._lsLoaderName
|
|
|
|
|
+ if (!name) return
|
|
|
|
|
+ const orig = this[name]
|
|
|
|
|
+ if (typeof orig !== 'function') return
|
|
|
|
|
+ const self = this
|
|
|
|
|
+ this[name] = async function(...args) {
|
|
|
|
|
+ const r = await orig.apply(this, args)
|
|
|
|
|
+ self.syncStateToUrl()
|
|
|
|
|
+ return r
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|