hookehuyr

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

新增 useScrollRestoration 组合式函数,用于在打卡列表页面实现滚动位置恢复
当用户从打卡详情页返回时,自动恢复到之前的滚动位置,支持锚点定位和日历高度补偿
添加对应的单元测试,覆盖等待条件、超时处理和条件恢复等场景
<!--
* @Date: 2025-01-25 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-23 09:33:40
* @LastEditTime: 2026-01-23 10:38:40
* @FilePath: /mlaj/src/components/calendar/CollapsibleCalendar.vue
* @Description: 可折叠日历组件
-->
......
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { useScrollRestoration } from '../useScrollRestoration'
const create_session_storage = () => {
const store = {}
return {
store,
setItem: vi.fn((key, value) => {
store[key] = String(value)
}),
getItem: vi.fn((key) => {
if (Object.prototype.hasOwnProperty.call(store, key)) return store[key]
return null
}),
removeItem: vi.fn((key) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach((key) => delete store[key])
}),
}
}
describe('useScrollRestoration', () => {
const original_window = globalThis.window
const original_session_storage = globalThis.sessionStorage
beforeEach(() => {
const session_storage = create_session_storage()
vi.stubGlobal('sessionStorage', session_storage)
const window_stub = {
scrollY: 0,
scrollTo: vi.fn(({ top }) => {
window_stub.scrollY = top
}),
}
vi.stubGlobal('window', window_stub)
})
afterEach(() => {
vi.unstubAllGlobals()
if (original_window !== undefined) vi.stubGlobal('window', original_window)
if (original_session_storage !== undefined) vi.stubGlobal('sessionStorage', original_session_storage)
vi.clearAllMocks()
})
it('restore_state 会等待 wait_for 达成后再执行滚动', async () => {
const { save_state, restore_state } = useScrollRestoration({
get_key: () => 'scroll_key',
get_scroll_el: () => window,
})
window.scrollY = 200
save_state({ extra: 1 })
window.scrollY = 0
let calls = 0
await restore_state({
wait_for: () => {
calls += 1
return calls >= 3
},
wait_for_timeout_ms: 200,
wait_for_interval_ms: 1,
settle_frames: 0,
get_scroll_top: () => 123,
})
expect(calls).toBeGreaterThanOrEqual(3)
expect(window.scrollTo).toHaveBeenCalled()
expect(window.scrollY).toBe(123)
})
it('wait_for 超时后仍会继续尝试恢复滚动', async () => {
const { save_state, restore_state } = useScrollRestoration({
get_key: () => 'scroll_key_timeout',
get_scroll_el: () => window,
})
window.scrollY = 80
save_state()
window.scrollY = 0
await restore_state({
wait_for: () => false,
wait_for_timeout_ms: 20,
wait_for_interval_ms: 5,
settle_frames: 0,
get_scroll_top: () => 50,
})
expect(window.scrollTo).toHaveBeenCalled()
expect(window.scrollY).toBe(50)
})
it('should_restore 返回 false 时不滚动且会清理状态', async () => {
const { save_state, restore_state, read_state } = useScrollRestoration({
get_key: () => 'scroll_key_should_restore',
get_scroll_el: () => window,
})
window.scrollY = 10
save_state()
await restore_state({
should_restore: () => false,
})
expect(window.scrollTo).not.toHaveBeenCalled()
expect(read_state()).toBeNull()
})
})
import { nextTick } from "vue";
export const useScrollRestoration = ({
get_key,
get_scroll_el,
max_age_ms = 30 * 60 * 1000,
is_log_enabled,
log_prefix = "scroll",
logger,
} = {}) => {
/**
* @description 输出滚动调试日志(默认关闭;由 is_log_enabled 控制)
* @param {...any} args - 日志参数
* @returns {void}
*/
const log = (...args) => {
const enabled = typeof is_log_enabled === "function" ? Boolean(is_log_enabled()) : Boolean(is_log_enabled);
if (!enabled) return;
if (typeof logger !== "function") return;
logger(`[${String(log_prefix || "scroll")}]`, ...args);
};
/**
* @description 等待一帧(优先使用 requestAnimationFrame;降级为 setTimeout)
* @returns {Promise<void>}
*/
const wait_frame = () => {
return new Promise((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => resolve());
return;
}
setTimeout(() => resolve(), 16);
});
};
/**
* @description 轮询等待条件达成(用于等待布局稳定/DOM 渲染完成)
* @param {Function} check - 返回 true 表示达成;支持返回 Promise
* @param {number} timeout_ms - 超时时间(0 表示不超时)
* @param {number} interval_ms - 轮询间隔(<=0 时按帧轮询)
* @returns {Promise<boolean>} 是否在超时前达成
*/
const wait_until = async (check, timeout_ms, interval_ms) => {
const start = Date.now();
const timeout = typeof timeout_ms === "number" ? timeout_ms : 0;
const interval = typeof interval_ms === "number" ? interval_ms : 16;
while (Date.now() - start < timeout || timeout === 0) {
let ok = false;
try {
ok = Boolean(await check());
} catch (e) {
ok = false;
}
if (ok) return true;
if (interval <= 0) {
await wait_frame();
} else {
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
return false;
};
/**
* @description 判断是否是“手动刷新”进入(用于处理浏览器滚动回填)
* @returns {boolean}
*/
const is_page_reload = () => {
try {
const entry =
typeof performance !== "undefined" && performance.getEntriesByType
? performance.getEntriesByType("navigation")?.[0]
: null;
if (entry && entry.type) return entry.type === "reload";
if (typeof performance !== "undefined" && performance.navigation) return performance.navigation.type === 1;
} catch (e) {
return false;
}
return false;
};
/**
* @description 计算本次滚动状态的存储 key(用于区分不同页面/参数)
* @returns {string}
*/
const resolve_key = () => {
if (typeof get_key === "function") return String(get_key() || "");
return String(get_key || "");
};
/**
* @description 解析滚动容器(支持 window 或元素滚动容器)
* @returns {Element|Window}
*/
const resolve_scroll_el = () => {
if (typeof get_scroll_el === "function") return get_scroll_el();
const el = typeof document !== "undefined" ? document.querySelector(".app-content") : null;
return el || window;
};
/**
* @description 获取当前滚动位置
* @param {Element|Window} scroll_el - 滚动容器
* @returns {number}
*/
const get_scroll_top = (scroll_el) => {
if (scroll_el === window) return window.scrollY || 0;
return scroll_el?.scrollTop || 0;
};
/**
* @description 设置滚动位置
* @param {Element|Window} scroll_el - 滚动容器
* @param {number} top - 目标滚动高度
* @param {string} behavior - 滚动行为
* @returns {void}
*/
const set_scroll_top = (scroll_el, top, behavior = "auto") => {
if (scroll_el === window) {
window.scrollTo({ top, left: 0, behavior });
return;
}
if (scroll_el && typeof scroll_el.scrollTo === "function") {
scroll_el.scrollTo({ top, left: 0, behavior });
return;
}
if (scroll_el) {
scroll_el.scrollTop = top;
}
};
/**
* @description 等待某个“就绪条件”达成(例如:布局/高度/元素渲染完成)
* @param {Function} check - 返回 true 表示就绪
* @param {Object} opts - 配置项
* @param {number} opts.timeout_ms - 超时时间
* @param {number} opts.interval_ms - 检查间隔
* @returns {Promise<boolean>} 是否在超时前就绪
*/
const wait_for_ready = async (check, { timeout_ms = 1500, interval_ms = 16 } = {}) => {
const ok = await wait_until(check, timeout_ms, interval_ms);
log("等待就绪结束", { ok, timeout_ms, interval_ms });
return ok;
};
/**
* @description 强制滚动到指定位置(可重复多次,对抗浏览器的滚动位置回填)
* @param {number} top - 目标滚动高度
* @param {Object} opts - 配置项
* @param {number} opts.times - 重复次数
* @param {number} opts.settle_frames - 每次滚动后等待的帧数
* @param {string} opts.behavior - 滚动行为
* @returns {Promise<{before_top:number, after_top:number}>}
*/
const force_scroll_to = async (
top,
{ times = 2, settle_frames = 1, behavior = "auto" } = {}
) => {
const scroll_el = resolve_scroll_el();
const repeat = Math.max(1, Number(times || 1));
const frames = Math.max(0, Number(settle_frames || 0));
let first_before = get_scroll_top(scroll_el);
let last_after = first_before;
for (let i = 0; i < repeat; i++) {
const before_top = get_scroll_top(scroll_el);
set_scroll_top(scroll_el, Math.max(0, Number(top || 0)), behavior);
for (let f = 0; f < frames; f++) {
await wait_frame();
}
last_after = get_scroll_top(scroll_el);
log("强制滚动执行", { round: i + 1, before_top, after_top: last_after, target_top: top });
}
return { before_top: first_before, after_top: last_after };
};
/**
* @description 读取当前 key 对应的滚动状态(自动处理 JSON 异常与过期)
* @returns {null|Object}
*/
const read_state = () => {
const key = resolve_key();
if (!key) return null;
const raw = sessionStorage.getItem(key);
if (!raw) return null;
try {
const state = JSON.parse(raw);
if (!state || typeof state !== "object") return null;
if (max_age_ms && state.saved_at && Date.now() - state.saved_at > max_age_ms) {
sessionStorage.removeItem(key);
return null;
}
return state;
} catch (e) {
sessionStorage.removeItem(key);
return null;
}
};
/**
* @description 清除当前 key 对应的滚动状态
* @returns {void}
*/
const clear_state = () => {
const key = resolve_key();
if (!key) return;
sessionStorage.removeItem(key);
};
/**
* @description 保存当前滚动位置到 sessionStorage(可附带业务字段)
* @param {Object} payload - 额外写入的状态字段(例如 anchor_id、calendar_height 等)
* @returns {void}
*/
const save_state = (payload = {}) => {
const key = resolve_key();
if (!key) return;
const scroll_el = resolve_scroll_el();
const scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el?.scrollTop || 0);
const state = {
scroll_top,
scroll_y: scroll_top,
saved_at: Date.now(),
...payload,
};
sessionStorage.setItem(key, JSON.stringify(state));
};
/**
* @description 恢复滚动状态(支持锚点恢复、等待就绪、条件恢复、恢复后清理)
* @param {Object} opts - 配置项
* @param {Function} opts.get_anchor_el - 通过 anchor_id 解析锚点元素
* @param {Function} opts.get_scroll_top - 计算目标滚动高度(优先级高于锚点)
* @param {Function} opts.should_restore - 是否允许恢复(返回 false 将跳过恢复)
* @param {boolean} opts.clear_after_restore - 是否在恢复后清理状态
* @param {string} opts.behavior - 滚动行为(auto/smooth)
* @param {Function} opts.wait_for - 等待条件(例如日历高度稳定/列表渲染完成)
* @param {number} opts.wait_for_timeout_ms - 等待超时
* @param {number} opts.wait_for_interval_ms - 等待间隔
* @param {number} opts.settle_frames - 恢复前额外等待的帧数(用于布局稳定)
* @returns {Promise<null|Object>} 返回恢复前读取到的 state;无恢复则为 null
*/
const restore_state = async ({
get_anchor_el,
get_scroll_top,
should_restore,
clear_after_restore = true,
behavior = "auto",
wait_for,
wait_for_timeout_ms = 1500,
wait_for_interval_ms = 16,
settle_frames = 2,
} = {}) => {
const state = read_state();
if (!state) return null;
const key = resolve_key();
const scroll_el = resolve_scroll_el();
if (typeof should_restore === "function" && !should_restore(state, scroll_el)) {
if (clear_after_restore) sessionStorage.removeItem(key);
return null;
}
await nextTick();
if (typeof wait_for === "function") {
await wait_until(
() => wait_for(state, scroll_el),
wait_for_timeout_ms,
wait_for_interval_ms
);
}
const frames = typeof settle_frames === "number" ? settle_frames : 0;
for (let i = 0; i < Math.max(0, frames); i++) {
await wait_frame();
}
let target_top = null;
if (typeof get_scroll_top === "function") {
target_top = get_scroll_top(state, scroll_el);
} else if (state.anchor_id && typeof get_anchor_el === "function") {
const anchor_el = get_anchor_el(state.anchor_id);
if (anchor_el) {
const scroll_rect = scroll_el === window ? { top: 0 } : scroll_el.getBoundingClientRect();
const anchor_rect = anchor_el.getBoundingClientRect();
const current_scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el.scrollTop || 0);
target_top = Math.max(0, anchor_rect.top - scroll_rect.top + current_scroll_top - 10);
}
}
if (typeof target_top !== "number") {
const fallback = typeof state.scroll_top === "number" ? state.scroll_top : state.scroll_y;
if (typeof fallback === "number") target_top = fallback;
}
if (typeof target_top === "number") {
if (scroll_el === window) {
window.scrollTo({ top: target_top, left: 0, behavior });
} else if (scroll_el && typeof scroll_el.scrollTo === "function") {
scroll_el.scrollTo({ top: target_top, left: 0, behavior });
} else if (scroll_el) {
scroll_el.scrollTop = target_top;
}
}
if (clear_after_restore) {
sessionStorage.removeItem(key);
}
return state;
};
return {
read_state,
save_state,
restore_state,
clear_state,
resolve_scroll_el,
log,
is_page_reload,
wait_for_ready,
force_scroll_to,
};
};
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-22 22:12:11
* @LastEditTime: 2026-01-23 10:46:15
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 用户打卡主页
-->
......@@ -10,19 +10,10 @@
<van-config-provider :theme-vars="themeVars">
<!-- 固定的日历组件 -->
<div class="fixed-calendar" ref="fixedCalendarWrapper">
<CollapsibleCalendar
ref="calendarRef"
:title="taskDetail.title"
:formatter="formatter"
:subtask-list="taskDetail.subtask_list"
:has-selected-date="hasUserSelectedDate"
v-model="selectedDate"
@select="onSelectDay"
@click-subtitle="onClickSubtitle"
@panel-change="onPanelChange"
@select-course="onSelectCourse"
@clear-date="onClearSelectedDate"
/>
<CollapsibleCalendar ref="calendarRef" :title="taskDetail.title" :formatter="formatter"
:subtask-list="taskDetail.subtask_list" :has-selected-date="hasUserSelectedDate" v-model="selectedDate"
@select="onSelectDay" @click-subtitle="onClickSubtitle" @panel-change="onPanelChange"
@select-course="onSelectCourse" @clear-date="onClearSelectedDate" />
</div>
<!-- 可滚动的内容区域 -->
......@@ -51,7 +42,8 @@
<!-- 累计次数 -->
<div class="flex-1 flex flex-col items-center">
<div class="flex items-baseline">
<span class="text-3xl font-bold text-[#ff9800] leading-none" style="color: #ff9800;">{{ myTotalGratitudeCount }}</span>
<span class="text-3xl font-bold text-[#ff9800] leading-none" style="color: #ff9800;">{{
myTotalGratitudeCount }}</span>
<span class="text-xs text-gray-400 ml-1 transform translate-y-0.5">次</span>
</div>
<div class="text-xs text-gray-500 mt-2">累计次数</div>
......@@ -88,7 +80,8 @@
<van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" />
</div> -->
<div style="padding: 0.75rem 1rem;">
<van-image round width="2.8rem" height="2.8rem" :src="item ? item : 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" fit="cover"
<van-image round width="2.8rem" height="2.8rem"
:src="item ? item : 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" fit="cover"
v-for="(item, index) in teamAvatars" :key="index"
:style="{ marginLeft: index > 0 ? '-0.5rem' : '', border: '2px solid #eff6ff', background: '#fff' }" />
</div>
......@@ -97,26 +90,11 @@
<div class="text-wrapper">
<div class="text-header">打卡动态</div>
<van-list
v-if="checkinDataList.length"
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
class="py-3 space-y-4"
>
<CheckinCard
v-for="post in checkinDataList"
:key="post.id"
:post="post"
:use-cdn-optimization="true"
:ref="(el) => setCheckinCardRef(el, post.id)"
@like="handLike"
@edit="editCheckin"
@delete="delCheckin"
@video-play="handleVideoPlay"
@audio-play="handleAudioPlay"
>
<van-list v-if="checkinDataList.length" v-model:loading="loading" :finished="finished" finished-text="没有更多了"
@load="onLoad" class="py-3 space-y-4">
<CheckinCard v-for="post in checkinDataList" :key="post.id" :post="post" :use-cdn-optimization="true"
:ref="(el) => setCheckinCardRef(el, post.id)" @like="handLike" @edit="editCheckin" @delete="delCheckin"
@video-play="handleVideoPlay" @audio-play="handleAudioPlay">
<template #content-top>
<div class="text-gray-500 font-bold text-sm mb-4">{{ post.subtask_title }}</div>
</template>
......@@ -134,13 +112,9 @@
<van-back-top right="5vw" bottom="25vh" offset="600" />
<!-- 底部悬浮打卡按钮 -->
<div v-if="is_task_detail_ready && !is_task_finished" class="floating-checkin-button" :class="{ 'is-compact': isCompactButton }">
<van-button
type="primary"
round
@click="goToCheckinDetailPage"
class="checkin-action-button"
>
<div v-if="is_task_detail_ready && !is_task_finished" class="floating-checkin-button"
:class="{ 'is-compact': isCompactButton }">
<van-button type="primary" round @click="goToCheckinDetailPage" class="checkin-action-button">
<van-icon name="edit" size="1.2rem" />
<span class="button-text">我要打卡</span>
</van-button>
......@@ -163,6 +137,7 @@ import FrostedGlass from "@/components/effects/FrostedGlass.vue";
import CollapsibleCalendar from "@/components/calendar/CollapsibleCalendar.vue";
import PostCountModel from "@/components/count/postCountModel.vue";
import CheckinCard from "@/components/checkin/CheckinCard.vue";
import { useScrollRestoration } from "@/composables/useScrollRestoration";
import { useTitle, useResizeObserver, useScroll } from '@vueuse/core';
import dayjs from 'dayjs';
......@@ -471,6 +446,7 @@ const getIconName = (type) => {
*/
const goToCheckinDetailPage = () => {
const current_date = route.query.date || dayjs().format('YYYY-MM-DD');
save_checkin_scroll_state({ return_full_path: route.fullPath, calendar_height: get_calendar_offset() })
router.push({
path: '/checkin/detail',
query: {
......@@ -543,6 +519,7 @@ const handLike = async (post) => {
const editCheckin = (post) => {
// 统一跳转到CheckinDetailPage页面处理所有类型的编辑
save_checkin_scroll_state({ anchor_id: post.id, return_full_path: route.fullPath, calendar_height: get_calendar_offset() })
router.push({
path: '/checkin/detail',
query: {
......@@ -613,7 +590,7 @@ const getTaskDetail = async (month) => {
if (code === 1) {
taskDetail.value = data;
is_task_detail_ready.value = true
progress1.value = ((data.checkin_number/data.target_number)*100).toFixed(1); // 计算进度条百分比
progress1.value = ((data.checkin_number / data.target_number) * 100).toFixed(1); // 计算进度条百分比
showProgress.value = !isNaN(progress1.value); // 如果是NaN,就不显示进度条
teamAvatars.value = taskDetail.value.checkin_avatars?.length > 8 ? taskDetail.value.checkin_avatars.splice(0, 8) : taskDetail.value.checkin_avatars;
// 获取当前用户的打卡日期
......@@ -789,13 +766,52 @@ const formatData = (data) => {
return formattedData;
}
// 保存滚动位置
const savedScrollTop = ref(0)
// 记录上次的taskId,用于判断是否切换了任务
const lastTaskId = ref('')
onDeactivated(() => {
savedScrollTop.value = window.scrollY
const get_checkin_scroll_key = () => {
const task_id = String(route.query.id || '')
const date = String(route.query.date || '')
const subtask_id = String(selectedSubtaskId.value || '')
return `checkin_index_scroll_${task_id}_${date}_${subtask_id}`
}
// ==================== 滚动恢复(返回/刷新) ====================
/**
* @description 获取顶部日历组件占用高度,用于计算滚动偏移
* @returns {number} 顶部日历高度(像素)
*/
const get_calendar_offset = () => {
const wrapper_height = fixedCalendarWrapper.value?.getBoundingClientRect?.().height
const fallback_height = Number(calendarHeight.value || 0)
const height = Number(wrapper_height || fallback_height || 0)
return height > 0 ? height : 0
}
/**
* @description 是否开启滚动调试日志
* @returns {boolean} 是否输出日志
*/
const is_scroll_log_enabled = () => {
return String(route.query.scroll_log || '') === '1' || sessionStorage.getItem('checkin_scroll_log') === '1'
}
const {
save_state: save_checkin_scroll_state,
restore_state: restore_checkin_scroll_state,
clear_state: clear_checkin_scroll_state,
resolve_scroll_el: resolve_checkin_scroll_el,
read_state: read_checkin_scroll_state,
log: log_scroll,
is_page_reload: is_checkin_page_reload,
wait_for_ready: wait_scroll_ready,
force_scroll_to: force_scroll_to,
} = useScrollRestoration({
get_key: get_checkin_scroll_key,
is_log_enabled: is_scroll_log_enabled,
log_prefix: 'checkin_scroll',
logger: (...args) => console.log(...args),
})
const refresh_checkin_list = async () => {
......@@ -809,6 +825,15 @@ const refresh_checkin_list = async () => {
}
onActivated(async () => {
// 注释:开启调试日志方式(二选一即可)
// 1) URL 追加参数:?scroll_log=1
// 2) 控制台执行:sessionStorage.setItem('checkin_scroll_log', '1')
log_scroll('页面激活', {
is_reload: is_checkin_page_reload(),
route_full_path: route.fullPath,
history_forward: window.history && window.history.state ? window.history.state.forward : null,
})
// 检查任务ID是否变化
// 注意:route.query.id 可能是数字或字符串,统一转为字符串比较
const currentId = String(route.query.id || '')
......@@ -819,15 +844,16 @@ onActivated(async () => {
// 如果任务ID变化,强制刷新整个页面数据
await initPage(route.query.date);
// 滚动到顶部
clear_checkin_scroll_state()
const scroll_el = resolve_checkin_scroll_el()
if (scroll_el === window) {
window.scrollTo(0, 0);
return;
} else if (scroll_el && typeof scroll_el.scrollTo === 'function') {
scroll_el.scrollTo({ top: 0, left: 0, behavior: 'auto' });
} else if (scroll_el) {
scroll_el.scrollTop = 0;
}
// 恢复滚动位置
if (savedScrollTop.value > 0) {
setTimeout(() => {
window.scrollTo(0, savedScrollTop.value)
}, 0)
return;
}
// 检查是否有数据刷新标记
......@@ -885,6 +911,82 @@ onActivated(async () => {
}
}
}
// 注释:读取当前保存的滚动状态,用于排查“没有执行/状态被清空”的问题
const saved_scroll_state = read_checkin_scroll_state()
log_scroll('读取滚动状态', saved_scroll_state)
/**
* @description 根据打卡ID获取卡片 DOM 元素,用于锚点滚动
* @param {string|number} anchor_id - 打卡动态ID
* @returns {Element|null} DOM 元素
*/
const resolve_anchor_el = (anchor_id) => {
const card = checkinCardRefs.value.get(anchor_id)
return card?.$el || card?.$?.vnode?.el || null
}
// 注释:优先尝试“从详情页返回”的滚动恢复;普通刷新不会走 should_restore
const restored_state = await restore_checkin_scroll_state({
wait_for: (state) => {
const wrapper_height = fixedCalendarWrapper.value?.getBoundingClientRect?.().height || 0
const height = Number(calendarHeight.value || 0)
if (wrapper_height <= 0 || height <= 0) return false
if (Math.abs(wrapper_height - height) > 2) return false
if (state?.anchor_id) return Boolean(resolve_anchor_el(state.anchor_id))
return true
},
settle_frames: 4,
should_restore: () => {
const forward = window.history && window.history.state ? window.history.state.forward : null
if (!forward) return false
return String(forward).includes('checkin/detail')
},
get_scroll_top: (state, scroll_el) => {
const current_calendar_height = get_calendar_offset()
if (state?.anchor_id) {
const anchor_el = resolve_anchor_el(state.anchor_id)
if (!anchor_el || typeof anchor_el.getBoundingClientRect !== 'function') return null
const scroll_rect_top = scroll_el === window ? 0 : (scroll_el?.getBoundingClientRect?.().top || 0)
const anchor_rect = anchor_el.getBoundingClientRect()
const current_scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el?.scrollTop || 0)
const base_top = anchor_rect.top - scroll_rect_top + current_scroll_top
return Math.max(0, base_top - current_calendar_height - 10)
}
const saved_top = typeof state.scroll_top === 'number' ? state.scroll_top : state.scroll_y
if (typeof saved_top !== 'number') return null
const saved_calendar_height = Number(state.calendar_height || 0)
if (saved_calendar_height > 0 && current_calendar_height > 0) {
return Math.max(0, saved_top + (saved_calendar_height - current_calendar_height))
}
return Math.max(0, saved_top)
},
get_anchor_el: resolve_anchor_el,
})
log_scroll('恢复滚动结果', { restored: Boolean(restored_state), restored_state })
// 注释:手动刷新时,如果没有走“返回详情页恢复”,则执行一次轻微滚动,避免顶部内容被遮挡
if (!restored_state && is_checkin_page_reload()) {
log_scroll('触发刷新兜底逻辑:准备强制滚动')
await nextTick()
await wait_scroll_ready(() => {
const wrapper_height = fixedCalendarWrapper.value?.getBoundingClientRect?.().height || 0
const height = Number(calendarHeight.value || 0)
if (wrapper_height <= 0 || height <= 0) return false
return Math.abs(wrapper_height - height) <= 2
}, { timeout_ms: 1500, interval_ms: 16 })
const forced_result = await force_scroll_to(0, { times: 3, settle_frames: 1, behavior: 'auto' })
log_scroll('刷新兜底逻辑结束', { forced_top: true, ...forced_result })
} else if (!restored_state) {
log_scroll('未恢复且不满足刷新兜底条件(不会强制滚动)', { is_reload: is_checkin_page_reload() })
}
})
</script>
......@@ -1021,8 +1123,6 @@ onActivated(async () => {
}
}
}
</style>
<style scoped>
......@@ -1035,7 +1135,8 @@ onActivated(async () => {
z-index: 10;
padding: 0 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none; /* 允许点击穿透,只响应按钮点击 */
pointer-events: none;
/* 允许点击穿透,只响应按钮点击 */
display: flex;
justify-content: center;
}
......