hookehuyr

feat(checkin): 添加滚动恢复功能以改善用户体验

新增 useScrollRestoration 组合式函数,用于在打卡列表页面实现滚动位置恢复
当用户从打卡详情页返回时,自动恢复到之前的滚动位置,支持锚点定位和日历高度补偿
添加对应的单元测试,覆盖等待条件、超时处理和条件恢复等场景
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 +})
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 }
......