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
...@@ -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>
......