feat(checkin): 添加滚动恢复功能以改善用户体验
新增 useScrollRestoration 组合式函数,用于在打卡列表页面实现滚动位置恢复 当用户从打卡详情页返回时,自动恢复到之前的滚动位置,支持锚点定位和日历高度补偿 添加对应的单元测试,覆盖等待条件、超时处理和条件恢复等场景
Showing
4 changed files
with
614 additions
and
59 deletions
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-01-25 15:34:17 | 2 | * @Date: 2025-01-25 15:34:17 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-01-23 09:33:40 | 4 | + * @LastEditTime: 2026-01-23 10:38:40 |
| 5 | * @FilePath: /mlaj/src/components/calendar/CollapsibleCalendar.vue | 5 | * @FilePath: /mlaj/src/components/calendar/CollapsibleCalendar.vue |
| 6 | * @Description: 可折叠日历组件 | 6 | * @Description: 可折叠日历组件 |
| 7 | --> | 7 | --> | ... | ... |
| 1 | +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' | ||
| 2 | +import { useScrollRestoration } from '../useScrollRestoration' | ||
| 3 | + | ||
| 4 | +const create_session_storage = () => { | ||
| 5 | + const store = {} | ||
| 6 | + return { | ||
| 7 | + store, | ||
| 8 | + setItem: vi.fn((key, value) => { | ||
| 9 | + store[key] = String(value) | ||
| 10 | + }), | ||
| 11 | + getItem: vi.fn((key) => { | ||
| 12 | + if (Object.prototype.hasOwnProperty.call(store, key)) return store[key] | ||
| 13 | + return null | ||
| 14 | + }), | ||
| 15 | + removeItem: vi.fn((key) => { | ||
| 16 | + delete store[key] | ||
| 17 | + }), | ||
| 18 | + clear: vi.fn(() => { | ||
| 19 | + Object.keys(store).forEach((key) => delete store[key]) | ||
| 20 | + }), | ||
| 21 | + } | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +describe('useScrollRestoration', () => { | ||
| 25 | + const original_window = globalThis.window | ||
| 26 | + const original_session_storage = globalThis.sessionStorage | ||
| 27 | + | ||
| 28 | + beforeEach(() => { | ||
| 29 | + const session_storage = create_session_storage() | ||
| 30 | + vi.stubGlobal('sessionStorage', session_storage) | ||
| 31 | + | ||
| 32 | + const window_stub = { | ||
| 33 | + scrollY: 0, | ||
| 34 | + scrollTo: vi.fn(({ top }) => { | ||
| 35 | + window_stub.scrollY = top | ||
| 36 | + }), | ||
| 37 | + } | ||
| 38 | + vi.stubGlobal('window', window_stub) | ||
| 39 | + }) | ||
| 40 | + | ||
| 41 | + afterEach(() => { | ||
| 42 | + vi.unstubAllGlobals() | ||
| 43 | + if (original_window !== undefined) vi.stubGlobal('window', original_window) | ||
| 44 | + if (original_session_storage !== undefined) vi.stubGlobal('sessionStorage', original_session_storage) | ||
| 45 | + vi.clearAllMocks() | ||
| 46 | + }) | ||
| 47 | + | ||
| 48 | + it('restore_state 会等待 wait_for 达成后再执行滚动', async () => { | ||
| 49 | + const { save_state, restore_state } = useScrollRestoration({ | ||
| 50 | + get_key: () => 'scroll_key', | ||
| 51 | + get_scroll_el: () => window, | ||
| 52 | + }) | ||
| 53 | + | ||
| 54 | + window.scrollY = 200 | ||
| 55 | + save_state({ extra: 1 }) | ||
| 56 | + | ||
| 57 | + window.scrollY = 0 | ||
| 58 | + let calls = 0 | ||
| 59 | + | ||
| 60 | + await restore_state({ | ||
| 61 | + wait_for: () => { | ||
| 62 | + calls += 1 | ||
| 63 | + return calls >= 3 | ||
| 64 | + }, | ||
| 65 | + wait_for_timeout_ms: 200, | ||
| 66 | + wait_for_interval_ms: 1, | ||
| 67 | + settle_frames: 0, | ||
| 68 | + get_scroll_top: () => 123, | ||
| 69 | + }) | ||
| 70 | + | ||
| 71 | + expect(calls).toBeGreaterThanOrEqual(3) | ||
| 72 | + expect(window.scrollTo).toHaveBeenCalled() | ||
| 73 | + expect(window.scrollY).toBe(123) | ||
| 74 | + }) | ||
| 75 | + | ||
| 76 | + it('wait_for 超时后仍会继续尝试恢复滚动', async () => { | ||
| 77 | + const { save_state, restore_state } = useScrollRestoration({ | ||
| 78 | + get_key: () => 'scroll_key_timeout', | ||
| 79 | + get_scroll_el: () => window, | ||
| 80 | + }) | ||
| 81 | + | ||
| 82 | + window.scrollY = 80 | ||
| 83 | + save_state() | ||
| 84 | + | ||
| 85 | + window.scrollY = 0 | ||
| 86 | + | ||
| 87 | + await restore_state({ | ||
| 88 | + wait_for: () => false, | ||
| 89 | + wait_for_timeout_ms: 20, | ||
| 90 | + wait_for_interval_ms: 5, | ||
| 91 | + settle_frames: 0, | ||
| 92 | + get_scroll_top: () => 50, | ||
| 93 | + }) | ||
| 94 | + | ||
| 95 | + expect(window.scrollTo).toHaveBeenCalled() | ||
| 96 | + expect(window.scrollY).toBe(50) | ||
| 97 | + }) | ||
| 98 | + | ||
| 99 | + it('should_restore 返回 false 时不滚动且会清理状态', async () => { | ||
| 100 | + const { save_state, restore_state, read_state } = useScrollRestoration({ | ||
| 101 | + get_key: () => 'scroll_key_should_restore', | ||
| 102 | + get_scroll_el: () => window, | ||
| 103 | + }) | ||
| 104 | + | ||
| 105 | + window.scrollY = 10 | ||
| 106 | + save_state() | ||
| 107 | + | ||
| 108 | + await restore_state({ | ||
| 109 | + should_restore: () => false, | ||
| 110 | + }) | ||
| 111 | + | ||
| 112 | + expect(window.scrollTo).not.toHaveBeenCalled() | ||
| 113 | + expect(read_state()).toBeNull() | ||
| 114 | + }) | ||
| 115 | +}) |
src/composables/useScrollRestoration.js
0 → 100644
| 1 | +import { nextTick } from "vue"; | ||
| 2 | + | ||
| 3 | +export const useScrollRestoration = ({ | ||
| 4 | + get_key, | ||
| 5 | + get_scroll_el, | ||
| 6 | + max_age_ms = 30 * 60 * 1000, | ||
| 7 | + is_log_enabled, | ||
| 8 | + log_prefix = "scroll", | ||
| 9 | + logger, | ||
| 10 | +} = {}) => { | ||
| 11 | + /** | ||
| 12 | + * @description 输出滚动调试日志(默认关闭;由 is_log_enabled 控制) | ||
| 13 | + * @param {...any} args - 日志参数 | ||
| 14 | + * @returns {void} | ||
| 15 | + */ | ||
| 16 | + const log = (...args) => { | ||
| 17 | + const enabled = typeof is_log_enabled === "function" ? Boolean(is_log_enabled()) : Boolean(is_log_enabled); | ||
| 18 | + if (!enabled) return; | ||
| 19 | + if (typeof logger !== "function") return; | ||
| 20 | + logger(`[${String(log_prefix || "scroll")}]`, ...args); | ||
| 21 | + }; | ||
| 22 | + | ||
| 23 | + /** | ||
| 24 | + * @description 等待一帧(优先使用 requestAnimationFrame;降级为 setTimeout) | ||
| 25 | + * @returns {Promise<void>} | ||
| 26 | + */ | ||
| 27 | + const wait_frame = () => { | ||
| 28 | + return new Promise((resolve) => { | ||
| 29 | + if (typeof requestAnimationFrame === "function") { | ||
| 30 | + requestAnimationFrame(() => resolve()); | ||
| 31 | + return; | ||
| 32 | + } | ||
| 33 | + setTimeout(() => resolve(), 16); | ||
| 34 | + }); | ||
| 35 | + }; | ||
| 36 | + | ||
| 37 | + /** | ||
| 38 | + * @description 轮询等待条件达成(用于等待布局稳定/DOM 渲染完成) | ||
| 39 | + * @param {Function} check - 返回 true 表示达成;支持返回 Promise | ||
| 40 | + * @param {number} timeout_ms - 超时时间(0 表示不超时) | ||
| 41 | + * @param {number} interval_ms - 轮询间隔(<=0 时按帧轮询) | ||
| 42 | + * @returns {Promise<boolean>} 是否在超时前达成 | ||
| 43 | + */ | ||
| 44 | + const wait_until = async (check, timeout_ms, interval_ms) => { | ||
| 45 | + const start = Date.now(); | ||
| 46 | + const timeout = typeof timeout_ms === "number" ? timeout_ms : 0; | ||
| 47 | + const interval = typeof interval_ms === "number" ? interval_ms : 16; | ||
| 48 | + | ||
| 49 | + while (Date.now() - start < timeout || timeout === 0) { | ||
| 50 | + let ok = false; | ||
| 51 | + try { | ||
| 52 | + ok = Boolean(await check()); | ||
| 53 | + } catch (e) { | ||
| 54 | + ok = false; | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + if (ok) return true; | ||
| 58 | + | ||
| 59 | + if (interval <= 0) { | ||
| 60 | + await wait_frame(); | ||
| 61 | + } else { | ||
| 62 | + await new Promise((resolve) => setTimeout(resolve, interval)); | ||
| 63 | + } | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + return false; | ||
| 67 | + }; | ||
| 68 | + | ||
| 69 | + /** | ||
| 70 | + * @description 判断是否是“手动刷新”进入(用于处理浏览器滚动回填) | ||
| 71 | + * @returns {boolean} | ||
| 72 | + */ | ||
| 73 | + const is_page_reload = () => { | ||
| 74 | + try { | ||
| 75 | + const entry = | ||
| 76 | + typeof performance !== "undefined" && performance.getEntriesByType | ||
| 77 | + ? performance.getEntriesByType("navigation")?.[0] | ||
| 78 | + : null; | ||
| 79 | + | ||
| 80 | + if (entry && entry.type) return entry.type === "reload"; | ||
| 81 | + if (typeof performance !== "undefined" && performance.navigation) return performance.navigation.type === 1; | ||
| 82 | + } catch (e) { | ||
| 83 | + return false; | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + return false; | ||
| 87 | + }; | ||
| 88 | + | ||
| 89 | + /** | ||
| 90 | + * @description 计算本次滚动状态的存储 key(用于区分不同页面/参数) | ||
| 91 | + * @returns {string} | ||
| 92 | + */ | ||
| 93 | + const resolve_key = () => { | ||
| 94 | + if (typeof get_key === "function") return String(get_key() || ""); | ||
| 95 | + return String(get_key || ""); | ||
| 96 | + }; | ||
| 97 | + | ||
| 98 | + /** | ||
| 99 | + * @description 解析滚动容器(支持 window 或元素滚动容器) | ||
| 100 | + * @returns {Element|Window} | ||
| 101 | + */ | ||
| 102 | + const resolve_scroll_el = () => { | ||
| 103 | + if (typeof get_scroll_el === "function") return get_scroll_el(); | ||
| 104 | + const el = typeof document !== "undefined" ? document.querySelector(".app-content") : null; | ||
| 105 | + return el || window; | ||
| 106 | + }; | ||
| 107 | + | ||
| 108 | + /** | ||
| 109 | + * @description 获取当前滚动位置 | ||
| 110 | + * @param {Element|Window} scroll_el - 滚动容器 | ||
| 111 | + * @returns {number} | ||
| 112 | + */ | ||
| 113 | + const get_scroll_top = (scroll_el) => { | ||
| 114 | + if (scroll_el === window) return window.scrollY || 0; | ||
| 115 | + return scroll_el?.scrollTop || 0; | ||
| 116 | + }; | ||
| 117 | + | ||
| 118 | + /** | ||
| 119 | + * @description 设置滚动位置 | ||
| 120 | + * @param {Element|Window} scroll_el - 滚动容器 | ||
| 121 | + * @param {number} top - 目标滚动高度 | ||
| 122 | + * @param {string} behavior - 滚动行为 | ||
| 123 | + * @returns {void} | ||
| 124 | + */ | ||
| 125 | + const set_scroll_top = (scroll_el, top, behavior = "auto") => { | ||
| 126 | + if (scroll_el === window) { | ||
| 127 | + window.scrollTo({ top, left: 0, behavior }); | ||
| 128 | + return; | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + if (scroll_el && typeof scroll_el.scrollTo === "function") { | ||
| 132 | + scroll_el.scrollTo({ top, left: 0, behavior }); | ||
| 133 | + return; | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + if (scroll_el) { | ||
| 137 | + scroll_el.scrollTop = top; | ||
| 138 | + } | ||
| 139 | + }; | ||
| 140 | + | ||
| 141 | + /** | ||
| 142 | + * @description 等待某个“就绪条件”达成(例如:布局/高度/元素渲染完成) | ||
| 143 | + * @param {Function} check - 返回 true 表示就绪 | ||
| 144 | + * @param {Object} opts - 配置项 | ||
| 145 | + * @param {number} opts.timeout_ms - 超时时间 | ||
| 146 | + * @param {number} opts.interval_ms - 检查间隔 | ||
| 147 | + * @returns {Promise<boolean>} 是否在超时前就绪 | ||
| 148 | + */ | ||
| 149 | + const wait_for_ready = async (check, { timeout_ms = 1500, interval_ms = 16 } = {}) => { | ||
| 150 | + const ok = await wait_until(check, timeout_ms, interval_ms); | ||
| 151 | + log("等待就绪结束", { ok, timeout_ms, interval_ms }); | ||
| 152 | + return ok; | ||
| 153 | + }; | ||
| 154 | + | ||
| 155 | + /** | ||
| 156 | + * @description 强制滚动到指定位置(可重复多次,对抗浏览器的滚动位置回填) | ||
| 157 | + * @param {number} top - 目标滚动高度 | ||
| 158 | + * @param {Object} opts - 配置项 | ||
| 159 | + * @param {number} opts.times - 重复次数 | ||
| 160 | + * @param {number} opts.settle_frames - 每次滚动后等待的帧数 | ||
| 161 | + * @param {string} opts.behavior - 滚动行为 | ||
| 162 | + * @returns {Promise<{before_top:number, after_top:number}>} | ||
| 163 | + */ | ||
| 164 | + const force_scroll_to = async ( | ||
| 165 | + top, | ||
| 166 | + { times = 2, settle_frames = 1, behavior = "auto" } = {} | ||
| 167 | + ) => { | ||
| 168 | + const scroll_el = resolve_scroll_el(); | ||
| 169 | + const repeat = Math.max(1, Number(times || 1)); | ||
| 170 | + const frames = Math.max(0, Number(settle_frames || 0)); | ||
| 171 | + | ||
| 172 | + let first_before = get_scroll_top(scroll_el); | ||
| 173 | + let last_after = first_before; | ||
| 174 | + | ||
| 175 | + for (let i = 0; i < repeat; i++) { | ||
| 176 | + const before_top = get_scroll_top(scroll_el); | ||
| 177 | + set_scroll_top(scroll_el, Math.max(0, Number(top || 0)), behavior); | ||
| 178 | + | ||
| 179 | + for (let f = 0; f < frames; f++) { | ||
| 180 | + await wait_frame(); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + last_after = get_scroll_top(scroll_el); | ||
| 184 | + log("强制滚动执行", { round: i + 1, before_top, after_top: last_after, target_top: top }); | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + return { before_top: first_before, after_top: last_after }; | ||
| 188 | + }; | ||
| 189 | + | ||
| 190 | + /** | ||
| 191 | + * @description 读取当前 key 对应的滚动状态(自动处理 JSON 异常与过期) | ||
| 192 | + * @returns {null|Object} | ||
| 193 | + */ | ||
| 194 | + const read_state = () => { | ||
| 195 | + const key = resolve_key(); | ||
| 196 | + if (!key) return null; | ||
| 197 | + const raw = sessionStorage.getItem(key); | ||
| 198 | + if (!raw) return null; | ||
| 199 | + try { | ||
| 200 | + const state = JSON.parse(raw); | ||
| 201 | + if (!state || typeof state !== "object") return null; | ||
| 202 | + if (max_age_ms && state.saved_at && Date.now() - state.saved_at > max_age_ms) { | ||
| 203 | + sessionStorage.removeItem(key); | ||
| 204 | + return null; | ||
| 205 | + } | ||
| 206 | + return state; | ||
| 207 | + } catch (e) { | ||
| 208 | + sessionStorage.removeItem(key); | ||
| 209 | + return null; | ||
| 210 | + } | ||
| 211 | + }; | ||
| 212 | + | ||
| 213 | + /** | ||
| 214 | + * @description 清除当前 key 对应的滚动状态 | ||
| 215 | + * @returns {void} | ||
| 216 | + */ | ||
| 217 | + const clear_state = () => { | ||
| 218 | + const key = resolve_key(); | ||
| 219 | + if (!key) return; | ||
| 220 | + sessionStorage.removeItem(key); | ||
| 221 | + }; | ||
| 222 | + | ||
| 223 | + /** | ||
| 224 | + * @description 保存当前滚动位置到 sessionStorage(可附带业务字段) | ||
| 225 | + * @param {Object} payload - 额外写入的状态字段(例如 anchor_id、calendar_height 等) | ||
| 226 | + * @returns {void} | ||
| 227 | + */ | ||
| 228 | + const save_state = (payload = {}) => { | ||
| 229 | + const key = resolve_key(); | ||
| 230 | + if (!key) return; | ||
| 231 | + const scroll_el = resolve_scroll_el(); | ||
| 232 | + const scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el?.scrollTop || 0); | ||
| 233 | + const state = { | ||
| 234 | + scroll_top, | ||
| 235 | + scroll_y: scroll_top, | ||
| 236 | + saved_at: Date.now(), | ||
| 237 | + ...payload, | ||
| 238 | + }; | ||
| 239 | + sessionStorage.setItem(key, JSON.stringify(state)); | ||
| 240 | + }; | ||
| 241 | + | ||
| 242 | + /** | ||
| 243 | + * @description 恢复滚动状态(支持锚点恢复、等待就绪、条件恢复、恢复后清理) | ||
| 244 | + * @param {Object} opts - 配置项 | ||
| 245 | + * @param {Function} opts.get_anchor_el - 通过 anchor_id 解析锚点元素 | ||
| 246 | + * @param {Function} opts.get_scroll_top - 计算目标滚动高度(优先级高于锚点) | ||
| 247 | + * @param {Function} opts.should_restore - 是否允许恢复(返回 false 将跳过恢复) | ||
| 248 | + * @param {boolean} opts.clear_after_restore - 是否在恢复后清理状态 | ||
| 249 | + * @param {string} opts.behavior - 滚动行为(auto/smooth) | ||
| 250 | + * @param {Function} opts.wait_for - 等待条件(例如日历高度稳定/列表渲染完成) | ||
| 251 | + * @param {number} opts.wait_for_timeout_ms - 等待超时 | ||
| 252 | + * @param {number} opts.wait_for_interval_ms - 等待间隔 | ||
| 253 | + * @param {number} opts.settle_frames - 恢复前额外等待的帧数(用于布局稳定) | ||
| 254 | + * @returns {Promise<null|Object>} 返回恢复前读取到的 state;无恢复则为 null | ||
| 255 | + */ | ||
| 256 | + const restore_state = async ({ | ||
| 257 | + get_anchor_el, | ||
| 258 | + get_scroll_top, | ||
| 259 | + should_restore, | ||
| 260 | + clear_after_restore = true, | ||
| 261 | + behavior = "auto", | ||
| 262 | + wait_for, | ||
| 263 | + wait_for_timeout_ms = 1500, | ||
| 264 | + wait_for_interval_ms = 16, | ||
| 265 | + settle_frames = 2, | ||
| 266 | + } = {}) => { | ||
| 267 | + const state = read_state(); | ||
| 268 | + if (!state) return null; | ||
| 269 | + | ||
| 270 | + const key = resolve_key(); | ||
| 271 | + const scroll_el = resolve_scroll_el(); | ||
| 272 | + | ||
| 273 | + if (typeof should_restore === "function" && !should_restore(state, scroll_el)) { | ||
| 274 | + if (clear_after_restore) sessionStorage.removeItem(key); | ||
| 275 | + return null; | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + await nextTick(); | ||
| 279 | + if (typeof wait_for === "function") { | ||
| 280 | + await wait_until( | ||
| 281 | + () => wait_for(state, scroll_el), | ||
| 282 | + wait_for_timeout_ms, | ||
| 283 | + wait_for_interval_ms | ||
| 284 | + ); | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + const frames = typeof settle_frames === "number" ? settle_frames : 0; | ||
| 288 | + for (let i = 0; i < Math.max(0, frames); i++) { | ||
| 289 | + await wait_frame(); | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + let target_top = null; | ||
| 293 | + | ||
| 294 | + if (typeof get_scroll_top === "function") { | ||
| 295 | + target_top = get_scroll_top(state, scroll_el); | ||
| 296 | + } else if (state.anchor_id && typeof get_anchor_el === "function") { | ||
| 297 | + const anchor_el = get_anchor_el(state.anchor_id); | ||
| 298 | + if (anchor_el) { | ||
| 299 | + const scroll_rect = scroll_el === window ? { top: 0 } : scroll_el.getBoundingClientRect(); | ||
| 300 | + const anchor_rect = anchor_el.getBoundingClientRect(); | ||
| 301 | + const current_scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el.scrollTop || 0); | ||
| 302 | + target_top = Math.max(0, anchor_rect.top - scroll_rect.top + current_scroll_top - 10); | ||
| 303 | + } | ||
| 304 | + } | ||
| 305 | + | ||
| 306 | + if (typeof target_top !== "number") { | ||
| 307 | + const fallback = typeof state.scroll_top === "number" ? state.scroll_top : state.scroll_y; | ||
| 308 | + if (typeof fallback === "number") target_top = fallback; | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + if (typeof target_top === "number") { | ||
| 312 | + if (scroll_el === window) { | ||
| 313 | + window.scrollTo({ top: target_top, left: 0, behavior }); | ||
| 314 | + } else if (scroll_el && typeof scroll_el.scrollTo === "function") { | ||
| 315 | + scroll_el.scrollTo({ top: target_top, left: 0, behavior }); | ||
| 316 | + } else if (scroll_el) { | ||
| 317 | + scroll_el.scrollTop = target_top; | ||
| 318 | + } | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + if (clear_after_restore) { | ||
| 322 | + sessionStorage.removeItem(key); | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + return state; | ||
| 326 | + }; | ||
| 327 | + | ||
| 328 | + return { | ||
| 329 | + read_state, | ||
| 330 | + save_state, | ||
| 331 | + restore_state, | ||
| 332 | + clear_state, | ||
| 333 | + resolve_scroll_el, | ||
| 334 | + log, | ||
| 335 | + is_page_reload, | ||
| 336 | + wait_for_ready, | ||
| 337 | + force_scroll_to, | ||
| 338 | + }; | ||
| 339 | +}; |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-05-29 15:34:17 | 2 | * @Date: 2025-05-29 15:34:17 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-01-22 22:12:11 | 4 | + * @LastEditTime: 2026-01-23 10:46:15 |
| 5 | * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue | 5 | * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue |
| 6 | * @Description: 用户打卡主页 | 6 | * @Description: 用户打卡主页 |
| 7 | --> | 7 | --> |
| ... | @@ -10,19 +10,10 @@ | ... | @@ -10,19 +10,10 @@ |
| 10 | <van-config-provider :theme-vars="themeVars"> | 10 | <van-config-provider :theme-vars="themeVars"> |
| 11 | <!-- 固定的日历组件 --> | 11 | <!-- 固定的日历组件 --> |
| 12 | <div class="fixed-calendar" ref="fixedCalendarWrapper"> | 12 | <div class="fixed-calendar" ref="fixedCalendarWrapper"> |
| 13 | - <CollapsibleCalendar | 13 | + <CollapsibleCalendar ref="calendarRef" :title="taskDetail.title" :formatter="formatter" |
| 14 | - ref="calendarRef" | 14 | + :subtask-list="taskDetail.subtask_list" :has-selected-date="hasUserSelectedDate" v-model="selectedDate" |
| 15 | - :title="taskDetail.title" | 15 | + @select="onSelectDay" @click-subtitle="onClickSubtitle" @panel-change="onPanelChange" |
| 16 | - :formatter="formatter" | 16 | + @select-course="onSelectCourse" @clear-date="onClearSelectedDate" /> |
| 17 | - :subtask-list="taskDetail.subtask_list" | ||
| 18 | - :has-selected-date="hasUserSelectedDate" | ||
| 19 | - v-model="selectedDate" | ||
| 20 | - @select="onSelectDay" | ||
| 21 | - @click-subtitle="onClickSubtitle" | ||
| 22 | - @panel-change="onPanelChange" | ||
| 23 | - @select-course="onSelectCourse" | ||
| 24 | - @clear-date="onClearSelectedDate" | ||
| 25 | - /> | ||
| 26 | </div> | 17 | </div> |
| 27 | 18 | ||
| 28 | <!-- 可滚动的内容区域 --> | 19 | <!-- 可滚动的内容区域 --> |
| ... | @@ -51,7 +42,8 @@ | ... | @@ -51,7 +42,8 @@ |
| 51 | <!-- 累计次数 --> | 42 | <!-- 累计次数 --> |
| 52 | <div class="flex-1 flex flex-col items-center"> | 43 | <div class="flex-1 flex flex-col items-center"> |
| 53 | <div class="flex items-baseline"> | 44 | <div class="flex items-baseline"> |
| 54 | - <span class="text-3xl font-bold text-[#ff9800] leading-none" style="color: #ff9800;">{{ myTotalGratitudeCount }}</span> | 45 | + <span class="text-3xl font-bold text-[#ff9800] leading-none" style="color: #ff9800;">{{ |
| 46 | + myTotalGratitudeCount }}</span> | ||
| 55 | <span class="text-xs text-gray-400 ml-1 transform translate-y-0.5">次</span> | 47 | <span class="text-xs text-gray-400 ml-1 transform translate-y-0.5">次</span> |
| 56 | </div> | 48 | </div> |
| 57 | <div class="text-xs text-gray-500 mt-2">累计次数</div> | 49 | <div class="text-xs text-gray-500 mt-2">累计次数</div> |
| ... | @@ -88,7 +80,8 @@ | ... | @@ -88,7 +80,8 @@ |
| 88 | <van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" /> | 80 | <van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" /> |
| 89 | </div> --> | 81 | </div> --> |
| 90 | <div style="padding: 0.75rem 1rem;"> | 82 | <div style="padding: 0.75rem 1rem;"> |
| 91 | - <van-image round width="2.8rem" height="2.8rem" :src="item ? item : 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" fit="cover" | 83 | + <van-image round width="2.8rem" height="2.8rem" |
| 84 | + :src="item ? item : 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" fit="cover" | ||
| 92 | v-for="(item, index) in teamAvatars" :key="index" | 85 | v-for="(item, index) in teamAvatars" :key="index" |
| 93 | :style="{ marginLeft: index > 0 ? '-0.5rem' : '', border: '2px solid #eff6ff', background: '#fff' }" /> | 86 | :style="{ marginLeft: index > 0 ? '-0.5rem' : '', border: '2px solid #eff6ff', background: '#fff' }" /> |
| 94 | </div> | 87 | </div> |
| ... | @@ -97,26 +90,11 @@ | ... | @@ -97,26 +90,11 @@ |
| 97 | 90 | ||
| 98 | <div class="text-wrapper"> | 91 | <div class="text-wrapper"> |
| 99 | <div class="text-header">打卡动态</div> | 92 | <div class="text-header">打卡动态</div> |
| 100 | - <van-list | 93 | + <van-list v-if="checkinDataList.length" v-model:loading="loading" :finished="finished" finished-text="没有更多了" |
| 101 | - v-if="checkinDataList.length" | 94 | + @load="onLoad" class="py-3 space-y-4"> |
| 102 | - v-model:loading="loading" | 95 | + <CheckinCard v-for="post in checkinDataList" :key="post.id" :post="post" :use-cdn-optimization="true" |
| 103 | - :finished="finished" | 96 | + :ref="(el) => setCheckinCardRef(el, post.id)" @like="handLike" @edit="editCheckin" @delete="delCheckin" |
| 104 | - finished-text="没有更多了" | 97 | + @video-play="handleVideoPlay" @audio-play="handleAudioPlay"> |
| 105 | - @load="onLoad" | ||
| 106 | - class="py-3 space-y-4" | ||
| 107 | - > | ||
| 108 | - <CheckinCard | ||
| 109 | - v-for="post in checkinDataList" | ||
| 110 | - :key="post.id" | ||
| 111 | - :post="post" | ||
| 112 | - :use-cdn-optimization="true" | ||
| 113 | - :ref="(el) => setCheckinCardRef(el, post.id)" | ||
| 114 | - @like="handLike" | ||
| 115 | - @edit="editCheckin" | ||
| 116 | - @delete="delCheckin" | ||
| 117 | - @video-play="handleVideoPlay" | ||
| 118 | - @audio-play="handleAudioPlay" | ||
| 119 | - > | ||
| 120 | <template #content-top> | 98 | <template #content-top> |
| 121 | <div class="text-gray-500 font-bold text-sm mb-4">{{ post.subtask_title }}</div> | 99 | <div class="text-gray-500 font-bold text-sm mb-4">{{ post.subtask_title }}</div> |
| 122 | </template> | 100 | </template> |
| ... | @@ -134,13 +112,9 @@ | ... | @@ -134,13 +112,9 @@ |
| 134 | <van-back-top right="5vw" bottom="25vh" offset="600" /> | 112 | <van-back-top right="5vw" bottom="25vh" offset="600" /> |
| 135 | 113 | ||
| 136 | <!-- 底部悬浮打卡按钮 --> | 114 | <!-- 底部悬浮打卡按钮 --> |
| 137 | - <div v-if="is_task_detail_ready && !is_task_finished" class="floating-checkin-button" :class="{ 'is-compact': isCompactButton }"> | 115 | + <div v-if="is_task_detail_ready && !is_task_finished" class="floating-checkin-button" |
| 138 | - <van-button | 116 | + :class="{ 'is-compact': isCompactButton }"> |
| 139 | - type="primary" | 117 | + <van-button type="primary" round @click="goToCheckinDetailPage" class="checkin-action-button"> |
| 140 | - round | ||
| 141 | - @click="goToCheckinDetailPage" | ||
| 142 | - class="checkin-action-button" | ||
| 143 | - > | ||
| 144 | <van-icon name="edit" size="1.2rem" /> | 118 | <van-icon name="edit" size="1.2rem" /> |
| 145 | <span class="button-text">我要打卡</span> | 119 | <span class="button-text">我要打卡</span> |
| 146 | </van-button> | 120 | </van-button> |
| ... | @@ -163,6 +137,7 @@ import FrostedGlass from "@/components/effects/FrostedGlass.vue"; | ... | @@ -163,6 +137,7 @@ import FrostedGlass from "@/components/effects/FrostedGlass.vue"; |
| 163 | import CollapsibleCalendar from "@/components/calendar/CollapsibleCalendar.vue"; | 137 | import CollapsibleCalendar from "@/components/calendar/CollapsibleCalendar.vue"; |
| 164 | import PostCountModel from "@/components/count/postCountModel.vue"; | 138 | import PostCountModel from "@/components/count/postCountModel.vue"; |
| 165 | import CheckinCard from "@/components/checkin/CheckinCard.vue"; | 139 | import CheckinCard from "@/components/checkin/CheckinCard.vue"; |
| 140 | +import { useScrollRestoration } from "@/composables/useScrollRestoration"; | ||
| 166 | import { useTitle, useResizeObserver, useScroll } from '@vueuse/core'; | 141 | import { useTitle, useResizeObserver, useScroll } from '@vueuse/core'; |
| 167 | import dayjs from 'dayjs'; | 142 | import dayjs from 'dayjs'; |
| 168 | 143 | ||
| ... | @@ -471,6 +446,7 @@ const getIconName = (type) => { | ... | @@ -471,6 +446,7 @@ const getIconName = (type) => { |
| 471 | */ | 446 | */ |
| 472 | const goToCheckinDetailPage = () => { | 447 | const goToCheckinDetailPage = () => { |
| 473 | const current_date = route.query.date || dayjs().format('YYYY-MM-DD'); | 448 | const current_date = route.query.date || dayjs().format('YYYY-MM-DD'); |
| 449 | + save_checkin_scroll_state({ return_full_path: route.fullPath, calendar_height: get_calendar_offset() }) | ||
| 474 | router.push({ | 450 | router.push({ |
| 475 | path: '/checkin/detail', | 451 | path: '/checkin/detail', |
| 476 | query: { | 452 | query: { |
| ... | @@ -543,6 +519,7 @@ const handLike = async (post) => { | ... | @@ -543,6 +519,7 @@ const handLike = async (post) => { |
| 543 | 519 | ||
| 544 | const editCheckin = (post) => { | 520 | const editCheckin = (post) => { |
| 545 | // 统一跳转到CheckinDetailPage页面处理所有类型的编辑 | 521 | // 统一跳转到CheckinDetailPage页面处理所有类型的编辑 |
| 522 | + save_checkin_scroll_state({ anchor_id: post.id, return_full_path: route.fullPath, calendar_height: get_calendar_offset() }) | ||
| 546 | router.push({ | 523 | router.push({ |
| 547 | path: '/checkin/detail', | 524 | path: '/checkin/detail', |
| 548 | query: { | 525 | query: { |
| ... | @@ -613,7 +590,7 @@ const getTaskDetail = async (month) => { | ... | @@ -613,7 +590,7 @@ const getTaskDetail = async (month) => { |
| 613 | if (code === 1) { | 590 | if (code === 1) { |
| 614 | taskDetail.value = data; | 591 | taskDetail.value = data; |
| 615 | is_task_detail_ready.value = true | 592 | is_task_detail_ready.value = true |
| 616 | - progress1.value = ((data.checkin_number/data.target_number)*100).toFixed(1); // 计算进度条百分比 | 593 | + progress1.value = ((data.checkin_number / data.target_number) * 100).toFixed(1); // 计算进度条百分比 |
| 617 | showProgress.value = !isNaN(progress1.value); // 如果是NaN,就不显示进度条 | 594 | showProgress.value = !isNaN(progress1.value); // 如果是NaN,就不显示进度条 |
| 618 | teamAvatars.value = taskDetail.value.checkin_avatars?.length > 8 ? taskDetail.value.checkin_avatars.splice(0, 8) : taskDetail.value.checkin_avatars; | 595 | teamAvatars.value = taskDetail.value.checkin_avatars?.length > 8 ? taskDetail.value.checkin_avatars.splice(0, 8) : taskDetail.value.checkin_avatars; |
| 619 | // 获取当前用户的打卡日期 | 596 | // 获取当前用户的打卡日期 |
| ... | @@ -789,13 +766,52 @@ const formatData = (data) => { | ... | @@ -789,13 +766,52 @@ const formatData = (data) => { |
| 789 | return formattedData; | 766 | return formattedData; |
| 790 | } | 767 | } |
| 791 | 768 | ||
| 792 | -// 保存滚动位置 | ||
| 793 | -const savedScrollTop = ref(0) | ||
| 794 | // 记录上次的taskId,用于判断是否切换了任务 | 769 | // 记录上次的taskId,用于判断是否切换了任务 |
| 795 | const lastTaskId = ref('') | 770 | const lastTaskId = ref('') |
| 796 | 771 | ||
| 797 | -onDeactivated(() => { | 772 | +const get_checkin_scroll_key = () => { |
| 798 | - savedScrollTop.value = window.scrollY | 773 | + const task_id = String(route.query.id || '') |
| 774 | + const date = String(route.query.date || '') | ||
| 775 | + const subtask_id = String(selectedSubtaskId.value || '') | ||
| 776 | + return `checkin_index_scroll_${task_id}_${date}_${subtask_id}` | ||
| 777 | +} | ||
| 778 | + | ||
| 779 | +// ==================== 滚动恢复(返回/刷新) ==================== | ||
| 780 | + | ||
| 781 | +/** | ||
| 782 | + * @description 获取顶部日历组件占用高度,用于计算滚动偏移 | ||
| 783 | + * @returns {number} 顶部日历高度(像素) | ||
| 784 | + */ | ||
| 785 | +const get_calendar_offset = () => { | ||
| 786 | + const wrapper_height = fixedCalendarWrapper.value?.getBoundingClientRect?.().height | ||
| 787 | + const fallback_height = Number(calendarHeight.value || 0) | ||
| 788 | + const height = Number(wrapper_height || fallback_height || 0) | ||
| 789 | + return height > 0 ? height : 0 | ||
| 790 | +} | ||
| 791 | + | ||
| 792 | +/** | ||
| 793 | + * @description 是否开启滚动调试日志 | ||
| 794 | + * @returns {boolean} 是否输出日志 | ||
| 795 | + */ | ||
| 796 | +const is_scroll_log_enabled = () => { | ||
| 797 | + return String(route.query.scroll_log || '') === '1' || sessionStorage.getItem('checkin_scroll_log') === '1' | ||
| 798 | +} | ||
| 799 | + | ||
| 800 | +const { | ||
| 801 | + save_state: save_checkin_scroll_state, | ||
| 802 | + restore_state: restore_checkin_scroll_state, | ||
| 803 | + clear_state: clear_checkin_scroll_state, | ||
| 804 | + resolve_scroll_el: resolve_checkin_scroll_el, | ||
| 805 | + read_state: read_checkin_scroll_state, | ||
| 806 | + log: log_scroll, | ||
| 807 | + is_page_reload: is_checkin_page_reload, | ||
| 808 | + wait_for_ready: wait_scroll_ready, | ||
| 809 | + force_scroll_to: force_scroll_to, | ||
| 810 | +} = useScrollRestoration({ | ||
| 811 | + get_key: get_checkin_scroll_key, | ||
| 812 | + is_log_enabled: is_scroll_log_enabled, | ||
| 813 | + log_prefix: 'checkin_scroll', | ||
| 814 | + logger: (...args) => console.log(...args), | ||
| 799 | }) | 815 | }) |
| 800 | 816 | ||
| 801 | const refresh_checkin_list = async () => { | 817 | const refresh_checkin_list = async () => { |
| ... | @@ -809,6 +825,15 @@ const refresh_checkin_list = async () => { | ... | @@ -809,6 +825,15 @@ const refresh_checkin_list = async () => { |
| 809 | } | 825 | } |
| 810 | 826 | ||
| 811 | onActivated(async () => { | 827 | onActivated(async () => { |
| 828 | + // 注释:开启调试日志方式(二选一即可) | ||
| 829 | + // 1) URL 追加参数:?scroll_log=1 | ||
| 830 | + // 2) 控制台执行:sessionStorage.setItem('checkin_scroll_log', '1') | ||
| 831 | + log_scroll('页面激活', { | ||
| 832 | + is_reload: is_checkin_page_reload(), | ||
| 833 | + route_full_path: route.fullPath, | ||
| 834 | + history_forward: window.history && window.history.state ? window.history.state.forward : null, | ||
| 835 | + }) | ||
| 836 | + | ||
| 812 | // 检查任务ID是否变化 | 837 | // 检查任务ID是否变化 |
| 813 | // 注意:route.query.id 可能是数字或字符串,统一转为字符串比较 | 838 | // 注意:route.query.id 可能是数字或字符串,统一转为字符串比较 |
| 814 | const currentId = String(route.query.id || '') | 839 | const currentId = String(route.query.id || '') |
| ... | @@ -819,15 +844,16 @@ onActivated(async () => { | ... | @@ -819,15 +844,16 @@ onActivated(async () => { |
| 819 | // 如果任务ID变化,强制刷新整个页面数据 | 844 | // 如果任务ID变化,强制刷新整个页面数据 |
| 820 | await initPage(route.query.date); | 845 | await initPage(route.query.date); |
| 821 | // 滚动到顶部 | 846 | // 滚动到顶部 |
| 847 | + clear_checkin_scroll_state() | ||
| 848 | + const scroll_el = resolve_checkin_scroll_el() | ||
| 849 | + if (scroll_el === window) { | ||
| 822 | window.scrollTo(0, 0); | 850 | window.scrollTo(0, 0); |
| 823 | - return; | 851 | + } else if (scroll_el && typeof scroll_el.scrollTo === 'function') { |
| 852 | + scroll_el.scrollTo({ top: 0, left: 0, behavior: 'auto' }); | ||
| 853 | + } else if (scroll_el) { | ||
| 854 | + scroll_el.scrollTop = 0; | ||
| 824 | } | 855 | } |
| 825 | - | 856 | + return; |
| 826 | - // 恢复滚动位置 | ||
| 827 | - if (savedScrollTop.value > 0) { | ||
| 828 | - setTimeout(() => { | ||
| 829 | - window.scrollTo(0, savedScrollTop.value) | ||
| 830 | - }, 0) | ||
| 831 | } | 857 | } |
| 832 | 858 | ||
| 833 | // 检查是否有数据刷新标记 | 859 | // 检查是否有数据刷新标记 |
| ... | @@ -885,6 +911,82 @@ onActivated(async () => { | ... | @@ -885,6 +911,82 @@ onActivated(async () => { |
| 885 | } | 911 | } |
| 886 | } | 912 | } |
| 887 | } | 913 | } |
| 914 | + | ||
| 915 | + // 注释:读取当前保存的滚动状态,用于排查“没有执行/状态被清空”的问题 | ||
| 916 | + const saved_scroll_state = read_checkin_scroll_state() | ||
| 917 | + log_scroll('读取滚动状态', saved_scroll_state) | ||
| 918 | + | ||
| 919 | + /** | ||
| 920 | + * @description 根据打卡ID获取卡片 DOM 元素,用于锚点滚动 | ||
| 921 | + * @param {string|number} anchor_id - 打卡动态ID | ||
| 922 | + * @returns {Element|null} DOM 元素 | ||
| 923 | + */ | ||
| 924 | + const resolve_anchor_el = (anchor_id) => { | ||
| 925 | + const card = checkinCardRefs.value.get(anchor_id) | ||
| 926 | + return card?.$el || card?.$?.vnode?.el || null | ||
| 927 | + } | ||
| 928 | + | ||
| 929 | + // 注释:优先尝试“从详情页返回”的滚动恢复;普通刷新不会走 should_restore | ||
| 930 | + const restored_state = await restore_checkin_scroll_state({ | ||
| 931 | + wait_for: (state) => { | ||
| 932 | + const wrapper_height = fixedCalendarWrapper.value?.getBoundingClientRect?.().height || 0 | ||
| 933 | + const height = Number(calendarHeight.value || 0) | ||
| 934 | + if (wrapper_height <= 0 || height <= 0) return false | ||
| 935 | + if (Math.abs(wrapper_height - height) > 2) return false | ||
| 936 | + | ||
| 937 | + if (state?.anchor_id) return Boolean(resolve_anchor_el(state.anchor_id)) | ||
| 938 | + return true | ||
| 939 | + }, | ||
| 940 | + settle_frames: 4, | ||
| 941 | + should_restore: () => { | ||
| 942 | + const forward = window.history && window.history.state ? window.history.state.forward : null | ||
| 943 | + if (!forward) return false | ||
| 944 | + return String(forward).includes('checkin/detail') | ||
| 945 | + }, | ||
| 946 | + get_scroll_top: (state, scroll_el) => { | ||
| 947 | + const current_calendar_height = get_calendar_offset() | ||
| 948 | + | ||
| 949 | + if (state?.anchor_id) { | ||
| 950 | + const anchor_el = resolve_anchor_el(state.anchor_id) | ||
| 951 | + if (!anchor_el || typeof anchor_el.getBoundingClientRect !== 'function') return null | ||
| 952 | + | ||
| 953 | + const scroll_rect_top = scroll_el === window ? 0 : (scroll_el?.getBoundingClientRect?.().top || 0) | ||
| 954 | + const anchor_rect = anchor_el.getBoundingClientRect() | ||
| 955 | + const current_scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el?.scrollTop || 0) | ||
| 956 | + const base_top = anchor_rect.top - scroll_rect_top + current_scroll_top | ||
| 957 | + return Math.max(0, base_top - current_calendar_height - 10) | ||
| 958 | + } | ||
| 959 | + | ||
| 960 | + const saved_top = typeof state.scroll_top === 'number' ? state.scroll_top : state.scroll_y | ||
| 961 | + if (typeof saved_top !== 'number') return null | ||
| 962 | + | ||
| 963 | + const saved_calendar_height = Number(state.calendar_height || 0) | ||
| 964 | + if (saved_calendar_height > 0 && current_calendar_height > 0) { | ||
| 965 | + return Math.max(0, saved_top + (saved_calendar_height - current_calendar_height)) | ||
| 966 | + } | ||
| 967 | + | ||
| 968 | + return Math.max(0, saved_top) | ||
| 969 | + }, | ||
| 970 | + get_anchor_el: resolve_anchor_el, | ||
| 971 | + }) | ||
| 972 | + | ||
| 973 | + log_scroll('恢复滚动结果', { restored: Boolean(restored_state), restored_state }) | ||
| 974 | + | ||
| 975 | + // 注释:手动刷新时,如果没有走“返回详情页恢复”,则执行一次轻微滚动,避免顶部内容被遮挡 | ||
| 976 | + if (!restored_state && is_checkin_page_reload()) { | ||
| 977 | + log_scroll('触发刷新兜底逻辑:准备强制滚动') | ||
| 978 | + await nextTick() | ||
| 979 | + await wait_scroll_ready(() => { | ||
| 980 | + const wrapper_height = fixedCalendarWrapper.value?.getBoundingClientRect?.().height || 0 | ||
| 981 | + const height = Number(calendarHeight.value || 0) | ||
| 982 | + if (wrapper_height <= 0 || height <= 0) return false | ||
| 983 | + return Math.abs(wrapper_height - height) <= 2 | ||
| 984 | + }, { timeout_ms: 1500, interval_ms: 16 }) | ||
| 985 | + const forced_result = await force_scroll_to(0, { times: 3, settle_frames: 1, behavior: 'auto' }) | ||
| 986 | + log_scroll('刷新兜底逻辑结束', { forced_top: true, ...forced_result }) | ||
| 987 | + } else if (!restored_state) { | ||
| 988 | + log_scroll('未恢复且不满足刷新兜底条件(不会强制滚动)', { is_reload: is_checkin_page_reload() }) | ||
| 989 | + } | ||
| 888 | }) | 990 | }) |
| 889 | </script> | 991 | </script> |
| 890 | 992 | ||
| ... | @@ -1021,8 +1123,6 @@ onActivated(async () => { | ... | @@ -1021,8 +1123,6 @@ onActivated(async () => { |
| 1021 | } | 1123 | } |
| 1022 | } | 1124 | } |
| 1023 | } | 1125 | } |
| 1024 | - | ||
| 1025 | - | ||
| 1026 | </style> | 1126 | </style> |
| 1027 | 1127 | ||
| 1028 | <style scoped> | 1128 | <style scoped> |
| ... | @@ -1035,7 +1135,8 @@ onActivated(async () => { | ... | @@ -1035,7 +1135,8 @@ onActivated(async () => { |
| 1035 | z-index: 10; | 1135 | z-index: 10; |
| 1036 | padding: 0 1rem; | 1136 | padding: 0 1rem; |
| 1037 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | 1137 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| 1038 | - pointer-events: none; /* 允许点击穿透,只响应按钮点击 */ | 1138 | + pointer-events: none; |
| 1139 | + /* 允许点击穿透,只响应按钮点击 */ | ||
| 1039 | display: flex; | 1140 | display: flex; |
| 1040 | justify-content: center; | 1141 | justify-content: center; |
| 1041 | } | 1142 | } | ... | ... |
-
Please register or login to post a comment