feat(checkin): 添加滚动恢复功能以改善用户体验
新增 useScrollRestoration 组合式函数,用于在打卡列表页面实现滚动位置恢复 当用户从打卡详情页返回时,自动恢复到之前的滚动位置,支持锚点定位和日历高度补偿 添加对应的单元测试,覆盖等待条件、超时处理和条件恢复等场景
Showing
4 changed files
with
859 additions
and
304 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 | ||
| ... | @@ -199,19 +174,19 @@ const calendarHeight = ref(200); // 默认高度 | ... | @@ -199,19 +174,19 @@ const calendarHeight = ref(200); // 默认高度 |
| 199 | 174 | ||
| 200 | // 使用 ResizeObserver 监听日历容器高度变化 | 175 | // 使用 ResizeObserver 监听日历容器高度变化 |
| 201 | useResizeObserver(fixedCalendarWrapper, (entries) => { | 176 | useResizeObserver(fixedCalendarWrapper, (entries) => { |
| 202 | - const entry = entries[0]; | 177 | + const entry = entries[0]; |
| 203 | - if (entry && entry.target) { | 178 | + if (entry && entry.target) { |
| 204 | - // 使用 getBoundingClientRect 获取包含 padding 和 border 的完整高度 | 179 | + // 使用 getBoundingClientRect 获取包含 padding 和 border 的完整高度 |
| 205 | - calendarHeight.value = entry.target.getBoundingClientRect().height; | 180 | + calendarHeight.value = entry.target.getBoundingClientRect().height; |
| 206 | - } | 181 | + } |
| 207 | }); | 182 | }); |
| 208 | 183 | ||
| 209 | /** | 184 | /** |
| 210 | * 监听窗口尺寸变化 | 185 | * 监听窗口尺寸变化 |
| 211 | */ | 186 | */ |
| 212 | const handleResize = () => { | 187 | const handleResize = () => { |
| 213 | - windowHeight.value = window.innerHeight; | 188 | + windowHeight.value = window.innerHeight; |
| 214 | - windowWidth.value = window.innerWidth; | 189 | + windowWidth.value = window.innerWidth; |
| 215 | }; | 190 | }; |
| 216 | 191 | ||
| 217 | // 组件挂载时添加事件监听 | 192 | // 组件挂载时添加事件监听 |
| ... | @@ -289,42 +264,42 @@ const formatter = (day) => { | ... | @@ -289,42 +264,42 @@ const formatter = (day) => { |
| 289 | 264 | ||
| 290 | // 如果选中的是全部作业,不执行打卡状态检查 | 265 | // 如果选中的是全部作业,不执行打卡状态检查 |
| 291 | if (selectedSubtaskId.value) { | 266 | if (selectedSubtaskId.value) { |
| 292 | - // 检查当前日期是否在签到日期列表中 | 267 | + // 检查当前日期是否在签到日期列表中 |
| 293 | - if (checkin_days && checkin_days.length > 0) { | 268 | + if (checkin_days && checkin_days.length > 0) { |
| 294 | - // 格式化当前日期为YYYY-MM-DD格式,与checkin_days中的格式匹配 | 269 | + // 格式化当前日期为YYYY-MM-DD格式,与checkin_days中的格式匹配 |
| 295 | - const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`; | 270 | + const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`; |
| 296 | - | 271 | + |
| 297 | - // 检查是否已打卡 | 272 | + // 检查是否已打卡 |
| 298 | - if (checkin_days.includes(formattedDate)) { | 273 | + if (checkin_days.includes(formattedDate)) { |
| 299 | - day.type = 'selected'; | 274 | + day.type = 'selected'; |
| 300 | - day.bottomInfo = '已打卡'; | 275 | + day.bottomInfo = '已打卡'; |
| 301 | - // 如果是当前选中的已打卡日期,使用特殊样式 | 276 | + // 如果是当前选中的已打卡日期,使用特殊样式 |
| 302 | - if (selectedDate.value === formattedDate) { | 277 | + if (selectedDate.value === formattedDate) { |
| 303 | - day.className = 'calendar-selected'; | 278 | + day.className = 'calendar-selected'; |
| 304 | - } else { | 279 | + } else { |
| 305 | - day.className = 'calendar-checkin'; | 280 | + day.className = 'calendar-checkin'; |
| 306 | - } | ||
| 307 | } | 281 | } |
| 308 | } | 282 | } |
| 283 | + } | ||
| 309 | 284 | ||
| 310 | - // 检查当前日期是否在补卡日期列表中 | 285 | + // 检查当前日期是否在补卡日期列表中 |
| 311 | - if (fill_checkin_days && fill_checkin_days.length > 0) { | 286 | + if (fill_checkin_days && fill_checkin_days.length > 0) { |
| 312 | - // 格式化当前日期为YYYY-MM-DD格式,与fill_checkin_days中的格式匹配 | 287 | + // 格式化当前日期为YYYY-MM-DD格式,与fill_checkin_days中的格式匹配 |
| 313 | - const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`; | 288 | + const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`; |
| 314 | - // 检查是否已补卡 | 289 | + // 检查是否已补卡 |
| 315 | - if (fill_checkin_days.includes(formattedDate)) { | 290 | + if (fill_checkin_days.includes(formattedDate)) { |
| 316 | - // 如果是当前选中的已补卡日期,使用特殊样式 | 291 | + // 如果是当前选中的已补卡日期,使用特殊样式 |
| 317 | - day.type = 'selected'; | 292 | + day.type = 'selected'; |
| 318 | - day.bottomInfo = '待补卡'; | 293 | + day.bottomInfo = '待补卡'; |
| 319 | - if (selectedDate.value === formattedDate) { | 294 | + if (selectedDate.value === formattedDate) { |
| 320 | - day.className = 'calendar-selected'; | 295 | + day.className = 'calendar-selected'; |
| 321 | - // 选中的是补卡日期 | 296 | + // 选中的是补卡日期 |
| 322 | - isPatchCheckin.value = true; | 297 | + isPatchCheckin.value = true; |
| 323 | - } else { | 298 | + } else { |
| 324 | - day.className = 'calendar-fill-checkin'; | 299 | + day.className = 'calendar-fill-checkin'; |
| 325 | - } | ||
| 326 | } | 300 | } |
| 327 | } | 301 | } |
| 302 | + } | ||
| 328 | } | 303 | } |
| 329 | 304 | ||
| 330 | // 选中今天的日期 | 305 | // 选中今天的日期 |
| ... | @@ -429,13 +404,13 @@ const onSelectCourse = (course) => { | ... | @@ -429,13 +404,13 @@ const onSelectCourse = (course) => { |
| 429 | * @returns {string} 图标名称 | 404 | * @returns {string} 图标名称 |
| 430 | */ | 405 | */ |
| 431 | const getIconName = (type) => { | 406 | const getIconName = (type) => { |
| 432 | - const iconMap = { | 407 | + const iconMap = { |
| 433 | - 'text': 'edit', | 408 | + 'text': 'edit', |
| 434 | - 'image': 'photo', | 409 | + 'image': 'photo', |
| 435 | - 'video': 'video', | 410 | + 'video': 'video', |
| 436 | - 'audio': 'music' | 411 | + 'audio': 'music' |
| 437 | - }; | 412 | + }; |
| 438 | - return iconMap[type] || 'edit'; | 413 | + return iconMap[type] || 'edit'; |
| 439 | }; | 414 | }; |
| 440 | 415 | ||
| 441 | /** | 416 | /** |
| ... | @@ -470,18 +445,19 @@ const getIconName = (type) => { | ... | @@ -470,18 +445,19 @@ const getIconName = (type) => { |
| 470 | * 业务逻辑调整, 需要把打卡类型带到下一页判断 | 445 | * 业务逻辑调整, 需要把打卡类型带到下一页判断 |
| 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'); |
| 474 | - router.push({ | 449 | + save_checkin_scroll_state({ return_full_path: route.fullPath, calendar_height: get_calendar_offset() }) |
| 475 | - path: '/checkin/detail', | 450 | + router.push({ |
| 476 | - query: { | 451 | + path: '/checkin/detail', |
| 477 | - post_id: route.query.id, | 452 | + query: { |
| 478 | - task_id: route.query.id, | 453 | + post_id: route.query.id, |
| 479 | - subtask_id: selectedSubtaskId.value, | 454 | + task_id: route.query.id, |
| 480 | - date: current_date, | 455 | + subtask_id: selectedSubtaskId.value, |
| 481 | - is_patch: isPatchCheckin.value ? '1' : '0', | 456 | + date: current_date, |
| 482 | - task_type: taskDetail.value.task_type, | 457 | + is_patch: isPatchCheckin.value ? '1' : '0', |
| 483 | - } | 458 | + task_type: taskDetail.value.task_type, |
| 484 | - }) | 459 | + } |
| 460 | + }) | ||
| 485 | } | 461 | } |
| 486 | 462 | ||
| 487 | // const goToCheckinTextPage = () => { | 463 | // const goToCheckinTextPage = () => { |
| ... | @@ -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: { |
| ... | @@ -573,15 +550,15 @@ const delCheckin = (post) => { | ... | @@ -573,15 +550,15 @@ const delCheckin = (post) => { |
| 573 | // checkinDataList.value = checkinDataList.value.filter(item => item.id !== post.id); | 550 | // checkinDataList.value = checkinDataList.value.filter(item => item.id !== post.id); |
| 574 | const index = checkinDataList.value.findIndex(item => item.id === post.id); | 551 | const index = checkinDataList.value.findIndex(item => item.id === post.id); |
| 575 | if (index > -1) { | 552 | if (index > -1) { |
| 576 | - checkinDataList.value.splice(index, 1); | 553 | + checkinDataList.value.splice(index, 1); |
| 577 | } | 554 | } |
| 578 | 555 | ||
| 579 | // 检查是否还可以打卡 | 556 | // 检查是否还可以打卡 |
| 580 | const current_date = route.query.date; | 557 | const current_date = route.query.date; |
| 581 | if (current_date) { | 558 | if (current_date) { |
| 582 | - getTaskDetail(dayjs(current_date).format('YYYY-MM')); | 559 | + getTaskDetail(dayjs(current_date).format('YYYY-MM')); |
| 583 | } else { | 560 | } else { |
| 584 | - getTaskDetail(dayjs().format('YYYY-MM')); | 561 | + getTaskDetail(dayjs().format('YYYY-MM')); |
| 585 | } | 562 | } |
| 586 | } else { | 563 | } else { |
| 587 | showErrorToast('删除失败'); | 564 | showErrorToast('删除失败'); |
| ... | @@ -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 | // 获取当前用户的打卡日期 |
| ... | @@ -667,56 +644,56 @@ const onLoad = async (date, isUserInitiated) => { | ... | @@ -667,56 +644,56 @@ const onLoad = async (date, isUserInitiated) => { |
| 667 | }; | 644 | }; |
| 668 | 645 | ||
| 669 | const initPage = async (date) => { | 646 | const initPage = async (date) => { |
| 670 | - // 重置数据 | 647 | + // 重置数据 |
| 671 | - checkinDataList.value = []; | 648 | + checkinDataList.value = []; |
| 672 | - page.value = 0; | 649 | + page.value = 0; |
| 673 | - finished.value = false; | 650 | + finished.value = false; |
| 674 | - loading.value = false; | 651 | + loading.value = false; |
| 675 | - taskDetail.value = {}; | 652 | + taskDetail.value = {}; |
| 676 | - is_task_detail_ready.value = false | 653 | + is_task_detail_ready.value = false |
| 677 | - selectedSubtaskId.value = ''; | 654 | + selectedSubtaskId.value = ''; |
| 678 | - | ||
| 679 | - const current_date = date || route.query.date; | ||
| 680 | - hasUserSelectedDate.value = !!current_date; | ||
| 681 | - if (current_date) { | ||
| 682 | - selectedDate.value = new Date(current_date); | ||
| 683 | - await getTaskDetail(dayjs(current_date).format('YYYY-MM')); | ||
| 684 | - // 确保日历组件已挂载再调用 reset | ||
| 685 | - nextTick(() => { | ||
| 686 | - calendarRef.value?.reset(new Date(current_date)); | ||
| 687 | - }) | ||
| 688 | - onLoad(current_date, true); | ||
| 689 | - } else { | ||
| 690 | - selectedDate.value = new Date(); | ||
| 691 | - await getTaskDetail(dayjs().format('YYYY-MM')); | ||
| 692 | - // 确保日历组件已挂载再调用 reset | ||
| 693 | - nextTick(() => { | ||
| 694 | - calendarRef.value?.reset(new Date()); | ||
| 695 | - }) | ||
| 696 | - onLoad(null, false); | ||
| 697 | - } | ||
| 698 | -} | ||
| 699 | 655 | ||
| 700 | -onMounted(async () => { | 656 | + const current_date = date || route.query.date; |
| 701 | - // 记录当前的taskId | 657 | + hasUserSelectedDate.value = !!current_date; |
| 702 | - lastTaskId.value = route.query.id; | 658 | + if (current_date) { |
| 703 | - await initPage(route.query.date); | 659 | + selectedDate.value = new Date(current_date); |
| 660 | + await getTaskDetail(dayjs(current_date).format('YYYY-MM')); | ||
| 661 | + // 确保日历组件已挂载再调用 reset | ||
| 662 | + nextTick(() => { | ||
| 663 | + calendarRef.value?.reset(new Date(current_date)); | ||
| 664 | + }) | ||
| 665 | + onLoad(current_date, true); | ||
| 666 | + } else { | ||
| 667 | + selectedDate.value = new Date(); | ||
| 668 | + await getTaskDetail(dayjs().format('YYYY-MM')); | ||
| 669 | + // 确保日历组件已挂载再调用 reset | ||
| 704 | nextTick(() => { | 670 | nextTick(() => { |
| 705 | - isInitializing.value = false; | 671 | + calendarRef.value?.reset(new Date()); |
| 706 | }) | 672 | }) |
| 673 | + onLoad(null, false); | ||
| 674 | + } | ||
| 675 | +} | ||
| 707 | 676 | ||
| 708 | - // 获取作品类型数据 | 677 | +onMounted(async () => { |
| 709 | - try { | 678 | + // 记录当前的taskId |
| 710 | - const { code, data } = await getTeacherFindSettingsAPI(); | 679 | + lastTaskId.value = route.query.id; |
| 711 | - if (code === 1 && data.task_attachment_type) { | 680 | + await initPage(route.query.date); |
| 712 | - attachmentTypeOptions.value = Object.entries(data.task_attachment_type).map(([key, value]) => ({ | 681 | + nextTick(() => { |
| 713 | - key, | 682 | + isInitializing.value = false; |
| 714 | - value | 683 | + }) |
| 715 | - })); | 684 | + |
| 716 | - } | 685 | + // 获取作品类型数据 |
| 717 | - } catch (error) { | 686 | + try { |
| 718 | - console.error('获取作品类型数据失败:', error); | 687 | + const { code, data } = await getTeacherFindSettingsAPI(); |
| 688 | + if (code === 1 && data.task_attachment_type) { | ||
| 689 | + attachmentTypeOptions.value = Object.entries(data.task_attachment_type).map(([key, value]) => ({ | ||
| 690 | + key, | ||
| 691 | + value | ||
| 692 | + })); | ||
| 719 | } | 693 | } |
| 694 | + } catch (error) { | ||
| 695 | + console.error('获取作品类型数据失败:', error); | ||
| 696 | + } | ||
| 720 | }) | 697 | }) |
| 721 | 698 | ||
| 722 | /** | 699 | /** |
| ... | @@ -725,187 +702,312 @@ onMounted(async () => { | ... | @@ -725,187 +702,312 @@ onMounted(async () => { |
| 725 | * 其他情况离开页面都清除筛选缓存 | 702 | * 其他情况离开页面都清除筛选缓存 |
| 726 | */ | 703 | */ |
| 727 | onBeforeRouteLeave((to, from) => { | 704 | onBeforeRouteLeave((to, from) => { |
| 728 | - // 检查目标路径是否是打卡详情页 | 705 | + // 检查目标路径是否是打卡详情页 |
| 729 | - if (!to.path.startsWith('/checkin/detail')) { | 706 | + if (!to.path.startsWith('/checkin/detail')) { |
| 730 | - sessionStorage.removeItem('collapsible_calendar_filter_state') | 707 | + sessionStorage.removeItem('collapsible_calendar_filter_state') |
| 731 | - // 关键:清除 lastTaskId,这样下次即使同ID进入,也会被视为新任务触发刷新 | 708 | + // 关键:清除 lastTaskId,这样下次即使同ID进入,也会被视为新任务触发刷新 |
| 732 | - lastTaskId.value = '' | 709 | + lastTaskId.value = '' |
| 733 | - } | 710 | + } |
| 734 | }) | 711 | }) |
| 735 | 712 | ||
| 736 | const formatData = (data) => { | 713 | const formatData = (data) => { |
| 737 | let formattedData = []; | 714 | let formattedData = []; |
| 738 | formattedData = data?.checkin_list.map((item, index) => { | 715 | formattedData = data?.checkin_list.map((item, index) => { |
| 739 | - let images = []; | 716 | + let images = []; |
| 740 | - let audio = []; | 717 | + let audio = []; |
| 741 | - let videoList = []; | 718 | + let videoList = []; |
| 742 | - if (item.file_type === 'image') { | 719 | + if (item.file_type === 'image') { |
| 743 | - images = item.files.map(file => { | 720 | + images = item.files.map(file => { |
| 744 | - return file.value; | 721 | + return file.value; |
| 745 | - }); | 722 | + }); |
| 746 | - } else if (item.file_type === 'video') { | 723 | + } else if (item.file_type === 'video') { |
| 747 | - videoList = item.files.map(file => { | 724 | + videoList = item.files.map(file => { |
| 748 | - return { | 725 | + return { |
| 749 | - id: file.meta_id, | 726 | + id: file.meta_id, |
| 750 | - video: file.value, | 727 | + video: file.value, |
| 751 | - videoCover: file.cover, | 728 | + videoCover: file.cover, |
| 752 | - isPlaying: false, | 729 | + isPlaying: false, |
| 753 | - } | 730 | + } |
| 754 | - }) | 731 | + }) |
| 755 | - } else if (item.file_type === 'audio') { | 732 | + } else if (item.file_type === 'audio') { |
| 756 | - audio = item.files.map(file => { | 733 | + audio = item.files.map(file => { |
| 757 | - return { | 734 | + return { |
| 758 | - title: file.name ? file.name : '打卡音频', | 735 | + title: file.name ? file.name : '打卡音频', |
| 759 | - artist: file.artist ? file.artist : '', | 736 | + artist: file.artist ? file.artist : '', |
| 760 | - url: file.value, | 737 | + url: file.value, |
| 761 | - cover: file.cover ? file.cover : '', | 738 | + cover: file.cover ? file.cover : '', |
| 762 | - } | 739 | + } |
| 763 | - }) | 740 | + }) |
| 764 | - } | 741 | + } |
| 765 | - return { | 742 | + return { |
| 766 | - id: item.id, | 743 | + id: item.id, |
| 767 | - task_id: item.task_id, | 744 | + task_id: item.task_id, |
| 768 | - user: { | 745 | + user: { |
| 769 | - name: item.username, | 746 | + name: item.username, |
| 770 | - avatar: item.avatar, | 747 | + avatar: item.avatar, |
| 771 | - time: item.created_time_desc, | 748 | + time: item.created_time_desc, |
| 772 | - is_makeup: item.is_makeup, | 749 | + is_makeup: item.is_makeup, |
| 773 | - }, | 750 | + }, |
| 774 | - content: item.note, | 751 | + content: item.note, |
| 775 | - images, | 752 | + images, |
| 776 | - videoList, | 753 | + videoList, |
| 777 | - audio, | 754 | + audio, |
| 778 | - isPlaying: false, | 755 | + isPlaying: false, |
| 779 | - likes: item.like_count, | 756 | + likes: item.like_count, |
| 780 | - is_liked: item.is_like, | 757 | + is_liked: item.is_like, |
| 781 | - is_my: item.is_my, | 758 | + is_my: item.is_my, |
| 782 | - file_type: item.file_type, | 759 | + file_type: item.file_type, |
| 783 | - subtask_title: item.subtask_title, | 760 | + subtask_title: item.subtask_title, |
| 784 | - subtask_id: item.subtask_id, | 761 | + subtask_id: item.subtask_id, |
| 785 | - gratitude_count: item.gratitude_count, | 762 | + gratitude_count: item.gratitude_count, |
| 786 | - gratitude_form_list: item.gratitude_form_list, | 763 | + gratitude_form_list: item.gratitude_form_list, |
| 787 | - } | 764 | + } |
| 788 | - }) | 765 | + }) |
| 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 () => { |
| 802 | - const current_date = route.query.date | 818 | + const current_date = route.query.date |
| 803 | - checkinDataList.value = [] | 819 | + checkinDataList.value = [] |
| 804 | - page.value = 0 | 820 | + page.value = 0 |
| 805 | - finished.value = false | 821 | + finished.value = false |
| 806 | - loading.value = true | 822 | + loading.value = true |
| 807 | - await getTaskDetail(dayjs(current_date || dayjs()).format('YYYY-MM')) | 823 | + await getTaskDetail(dayjs(current_date || dayjs()).format('YYYY-MM')) |
| 808 | - await onLoad(current_date, hasUserSelectedDate.value) | 824 | + await onLoad(current_date, hasUserSelectedDate.value) |
| 809 | } | 825 | } |
| 810 | 826 | ||
| 811 | onActivated(async () => { | 827 | onActivated(async () => { |
| 812 | - // 检查任务ID是否变化 | 828 | + // 注释:开启调试日志方式(二选一即可) |
| 813 | - // 注意:route.query.id 可能是数字或字符串,统一转为字符串比较 | 829 | + // 1) URL 追加参数:?scroll_log=1 |
| 814 | - const currentId = String(route.query.id || '') | 830 | + // 2) 控制台执行:sessionStorage.setItem('checkin_scroll_log', '1') |
| 815 | - const lastId = String(lastTaskId.value || '') | 831 | + log_scroll('页面激活', { |
| 816 | - | 832 | + is_reload: is_checkin_page_reload(), |
| 817 | - if (currentId && currentId !== lastId) { | 833 | + route_full_path: route.fullPath, |
| 818 | - lastTaskId.value = currentId; | 834 | + history_forward: window.history && window.history.state ? window.history.state.forward : null, |
| 819 | - // 如果任务ID变化,强制刷新整个页面数据 | 835 | + }) |
| 820 | - await initPage(route.query.date); | ||
| 821 | - // 滚动到顶部 | ||
| 822 | - window.scrollTo(0, 0); | ||
| 823 | - return; | ||
| 824 | - } | ||
| 825 | 836 | ||
| 826 | - // 恢复滚动位置 | 837 | + // 检查任务ID是否变化 |
| 827 | - if (savedScrollTop.value > 0) { | 838 | + // 注意:route.query.id 可能是数字或字符串,统一转为字符串比较 |
| 828 | - setTimeout(() => { | 839 | + const currentId = String(route.query.id || '') |
| 829 | - window.scrollTo(0, savedScrollTop.value) | 840 | + const lastId = String(lastTaskId.value || '') |
| 830 | - }, 0) | 841 | + |
| 842 | + if (currentId && currentId !== lastId) { | ||
| 843 | + lastTaskId.value = currentId; | ||
| 844 | + // 如果任务ID变化,强制刷新整个页面数据 | ||
| 845 | + await initPage(route.query.date); | ||
| 846 | + // 滚动到顶部 | ||
| 847 | + clear_checkin_scroll_state() | ||
| 848 | + const scroll_el = resolve_checkin_scroll_el() | ||
| 849 | + if (scroll_el === window) { | ||
| 850 | + window.scrollTo(0, 0); | ||
| 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; | ||
| 831 | } | 855 | } |
| 856 | + return; | ||
| 857 | + } | ||
| 832 | 858 | ||
| 833 | - // 检查是否有数据刷新标记 | 859 | + // 检查是否有数据刷新标记 |
| 834 | - const refreshType = sessionStorage.getItem('checkin_refresh_flag') | 860 | + const refreshType = sessionStorage.getItem('checkin_refresh_flag') |
| 835 | - const refreshId = sessionStorage.getItem('checkin_refresh_id') | 861 | + const refreshId = sessionStorage.getItem('checkin_refresh_id') |
| 836 | - | 862 | + |
| 837 | - if (refreshType) { | 863 | + if (refreshType) { |
| 838 | - // 清除标记 | 864 | + // 清除标记 |
| 839 | - sessionStorage.removeItem('checkin_refresh_flag') | 865 | + sessionStorage.removeItem('checkin_refresh_flag') |
| 840 | - sessionStorage.removeItem('checkin_refresh_id') | 866 | + sessionStorage.removeItem('checkin_refresh_id') |
| 841 | - | 867 | + |
| 842 | - let has_refreshed = false | 868 | + let has_refreshed = false |
| 843 | - if (refreshId) { | 869 | + if (refreshId) { |
| 844 | - try { | 870 | + try { |
| 845 | - // 获取最新的打卡信息 | 871 | + // 获取最新的打卡信息 |
| 846 | - const { code, data } = await getUploadTaskInfoAPI({ i: refreshId }) | 872 | + const { code, data } = await getUploadTaskInfoAPI({ i: refreshId }) |
| 847 | - if (code === 1 && data) { | 873 | + if (code === 1 && data) { |
| 848 | - // 构造伪造的 data 对象以适配 formatData | 874 | + // 构造伪造的 data 对象以适配 formatData |
| 849 | - const mockData = { checkin_list: [data] } | 875 | + const mockData = { checkin_list: [data] } |
| 850 | - const formattedList = formatData(mockData) | 876 | + const formattedList = formatData(mockData) |
| 851 | - | 877 | + |
| 852 | - if (formattedList && formattedList.length > 0) { | 878 | + if (formattedList && formattedList.length > 0) { |
| 853 | - const formattedItem = formattedList[0] | 879 | + const formattedItem = formattedList[0] |
| 854 | - | 880 | + |
| 855 | - if (refreshType === 'edit') { | 881 | + if (refreshType === 'edit') { |
| 856 | - // 编辑模式:更新列表中的对应项 | 882 | + // 编辑模式:更新列表中的对应项 |
| 857 | - const index = checkinDataList.value.findIndex(item => item.id == refreshId) | 883 | + const index = checkinDataList.value.findIndex(item => item.id == refreshId) |
| 858 | - if (index > -1) { | 884 | + if (index > -1) { |
| 859 | - checkinDataList.value.splice(index, 1, formattedItem) | 885 | + checkinDataList.value.splice(index, 1, formattedItem) |
| 860 | - } | 886 | + } |
| 861 | - has_refreshed = true | 887 | + has_refreshed = true |
| 862 | - } else if (refreshType === 'add') { | 888 | + } else if (refreshType === 'add') { |
| 863 | - // 新增模式:添加到列表顶部,且去重 | 889 | + // 新增模式:添加到列表顶部,且去重 |
| 864 | - const exists = checkinDataList.value.some(item => item.id == formattedItem.id) | 890 | + const exists = checkinDataList.value.some(item => item.id == formattedItem.id) |
| 865 | - if (!exists) { | 891 | + if (!exists) { |
| 866 | - checkinDataList.value.unshift(formattedItem) | 892 | + checkinDataList.value.unshift(formattedItem) |
| 867 | - } | 893 | + } |
| 868 | - // 更新统计数据 | 894 | + // 更新统计数据 |
| 869 | - const current_date = route.query.date || dayjs().format('YYYY-MM-DD'); | 895 | + const current_date = route.query.date || dayjs().format('YYYY-MM-DD'); |
| 870 | - getTaskDetail(dayjs(current_date).format('YYYY-MM')); | 896 | + getTaskDetail(dayjs(current_date).format('YYYY-MM')); |
| 871 | - has_refreshed = true | 897 | + has_refreshed = true |
| 872 | - } | ||
| 873 | - } | ||
| 874 | - } | ||
| 875 | - } catch (error) { | ||
| 876 | - console.error('刷新打卡数据失败:', error) | ||
| 877 | } | 898 | } |
| 899 | + } | ||
| 878 | } | 900 | } |
| 901 | + } catch (error) { | ||
| 902 | + console.error('刷新打卡数据失败:', error) | ||
| 903 | + } | ||
| 904 | + } | ||
| 879 | 905 | ||
| 880 | - if (!has_refreshed) { | 906 | + if (!has_refreshed) { |
| 881 | - try { | 907 | + try { |
| 882 | - await refresh_checkin_list() | 908 | + await refresh_checkin_list() |
| 883 | - } catch (error) { | 909 | + } catch (error) { |
| 884 | - console.error('回退刷新打卡列表失败:', error) | 910 | + console.error('回退刷新打卡列表失败:', error) |
| 885 | - } | 911 | + } |
| 886 | - } | ||
| 887 | } | 912 | } |
| 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 | ||
| 891 | <style lang="less"> | 993 | <style lang="less"> |
| 892 | // 固定日历样式 | 994 | // 固定日历样式 |
| 893 | .fixed-calendar { | 995 | .fixed-calendar { |
| 894 | - position: fixed; | 996 | + position: fixed; |
| 895 | - top: 0; | 997 | + top: 0; |
| 896 | - left: 0; | 998 | + left: 0; |
| 897 | - right: 0; | 999 | + right: 0; |
| 898 | - z-index: 1000; | 1000 | + z-index: 1000; |
| 899 | - background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff); // 与AppLayout保持一致的渐变背景 | 1001 | + background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff); // 与AppLayout保持一致的渐变背景 |
| 900 | - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | 1002 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
| 901 | } | 1003 | } |
| 902 | 1004 | ||
| 903 | // 可滚动内容区域样式 | 1005 | // 可滚动内容区域样式 |
| 904 | .scrollable-content { | 1006 | .scrollable-content { |
| 905 | - margin-top: v-bind('calendarHeight + "px"'); // 动态计算日历高度 | 1007 | + margin-top: v-bind('calendarHeight + "px"'); // 动态计算日历高度 |
| 906 | - padding-top: 1rem; | 1008 | + padding-top: 1rem; |
| 907 | - padding-bottom: 6rem; // 添加底部padding,避免内容被底部按钮遮挡 | 1009 | + padding-bottom: 6rem; // 添加底部padding,避免内容被底部按钮遮挡 |
| 908 | - // 移除固定高度和overflow,让AppLayout处理滚动 | 1010 | + // 移除固定高度和overflow,让AppLayout处理滚动 |
| 909 | } | 1011 | } |
| 910 | 1012 | ||
| 911 | .van-back-top { | 1013 | .van-back-top { |
| ... | @@ -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 | } |
| ... | @@ -1092,16 +1193,16 @@ onActivated(async () => { | ... | @@ -1092,16 +1193,16 @@ onActivated(async () => { |
| 1092 | } | 1193 | } |
| 1093 | 1194 | ||
| 1094 | :deep(.van-calendar__footer) { | 1195 | :deep(.van-calendar__footer) { |
| 1095 | - display: none; | 1196 | + display: none; |
| 1096 | } | 1197 | } |
| 1097 | 1198 | ||
| 1098 | /* 禁用未来日期的样式 */ | 1199 | /* 禁用未来日期的样式 */ |
| 1099 | :deep(.calendar-disabled) { | 1200 | :deep(.calendar-disabled) { |
| 1100 | - color: #c8c9cc !important; | 1201 | + color: #c8c9cc !important; |
| 1101 | - cursor: not-allowed !important; | 1202 | + cursor: not-allowed !important; |
| 1102 | } | 1203 | } |
| 1103 | 1204 | ||
| 1104 | :deep(.calendar-disabled .van-calendar__day-text) { | 1205 | :deep(.calendar-disabled .van-calendar__day-text) { |
| 1105 | - color: #c8c9cc !important; | 1206 | + color: #c8c9cc !important; |
| 1106 | } | 1207 | } |
| 1107 | </style> | 1208 | </style> | ... | ... |
-
Please register or login to post a comment