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';
......@@ -199,19 +174,19 @@ const calendarHeight = ref(200); // 默认高度
// 使用 ResizeObserver 监听日历容器高度变化
useResizeObserver(fixedCalendarWrapper, (entries) => {
const entry = entries[0];
if (entry && entry.target) {
// 使用 getBoundingClientRect 获取包含 padding 和 border 的完整高度
calendarHeight.value = entry.target.getBoundingClientRect().height;
}
const entry = entries[0];
if (entry && entry.target) {
// 使用 getBoundingClientRect 获取包含 padding 和 border 的完整高度
calendarHeight.value = entry.target.getBoundingClientRect().height;
}
});
/**
* 监听窗口尺寸变化
*/
const handleResize = () => {
windowHeight.value = window.innerHeight;
windowWidth.value = window.innerWidth;
windowHeight.value = window.innerHeight;
windowWidth.value = window.innerWidth;
};
// 组件挂载时添加事件监听
......@@ -289,42 +264,42 @@ const formatter = (day) => {
// 如果选中的是全部作业,不执行打卡状态检查
if (selectedSubtaskId.value) {
// 检查当前日期是否在签到日期列表中
if (checkin_days && checkin_days.length > 0) {
// 格式化当前日期为YYYY-MM-DD格式,与checkin_days中的格式匹配
const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`;
// 检查是否已打卡
if (checkin_days.includes(formattedDate)) {
day.type = 'selected';
day.bottomInfo = '已打卡';
// 如果是当前选中的已打卡日期,使用特殊样式
if (selectedDate.value === formattedDate) {
day.className = 'calendar-selected';
} else {
day.className = 'calendar-checkin';
}
// 检查当前日期是否在签到日期列表中
if (checkin_days && checkin_days.length > 0) {
// 格式化当前日期为YYYY-MM-DD格式,与checkin_days中的格式匹配
const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`;
// 检查是否已打卡
if (checkin_days.includes(formattedDate)) {
day.type = 'selected';
day.bottomInfo = '已打卡';
// 如果是当前选中的已打卡日期,使用特殊样式
if (selectedDate.value === formattedDate) {
day.className = 'calendar-selected';
} else {
day.className = 'calendar-checkin';
}
}
}
// 检查当前日期是否在补卡日期列表中
if (fill_checkin_days && fill_checkin_days.length > 0) {
// 格式化当前日期为YYYY-MM-DD格式,与fill_checkin_days中的格式匹配
const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`;
// 检查是否已补卡
if (fill_checkin_days.includes(formattedDate)) {
// 如果是当前选中的已补卡日期,使用特殊样式
day.type = 'selected';
day.bottomInfo = '待补卡';
if (selectedDate.value === formattedDate) {
day.className = 'calendar-selected';
// 选中的是补卡日期
isPatchCheckin.value = true;
} else {
day.className = 'calendar-fill-checkin';
}
// 检查当前日期是否在补卡日期列表中
if (fill_checkin_days && fill_checkin_days.length > 0) {
// 格式化当前日期为YYYY-MM-DD格式,与fill_checkin_days中的格式匹配
const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`;
// 检查是否已补卡
if (fill_checkin_days.includes(formattedDate)) {
// 如果是当前选中的已补卡日期,使用特殊样式
day.type = 'selected';
day.bottomInfo = '待补卡';
if (selectedDate.value === formattedDate) {
day.className = 'calendar-selected';
// 选中的是补卡日期
isPatchCheckin.value = true;
} else {
day.className = 'calendar-fill-checkin';
}
}
}
}
// 选中今天的日期
......@@ -429,13 +404,13 @@ const onSelectCourse = (course) => {
* @returns {string} 图标名称
*/
const getIconName = (type) => {
const iconMap = {
'text': 'edit',
'image': 'photo',
'video': 'video',
'audio': 'music'
};
return iconMap[type] || 'edit';
const iconMap = {
'text': 'edit',
'image': 'photo',
'video': 'video',
'audio': 'music'
};
return iconMap[type] || 'edit';
};
/**
......@@ -470,18 +445,19 @@ const getIconName = (type) => {
* 业务逻辑调整, 需要把打卡类型带到下一页判断
*/
const goToCheckinDetailPage = () => {
const current_date = route.query.date || dayjs().format('YYYY-MM-DD');
router.push({
path: '/checkin/detail',
query: {
post_id: route.query.id,
task_id: route.query.id,
subtask_id: selectedSubtaskId.value,
date: current_date,
is_patch: isPatchCheckin.value ? '1' : '0',
task_type: taskDetail.value.task_type,
}
})
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: {
post_id: route.query.id,
task_id: route.query.id,
subtask_id: selectedSubtaskId.value,
date: current_date,
is_patch: isPatchCheckin.value ? '1' : '0',
task_type: taskDetail.value.task_type,
}
})
}
// const goToCheckinTextPage = () => {
......@@ -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: {
......@@ -573,15 +550,15 @@ const delCheckin = (post) => {
// checkinDataList.value = checkinDataList.value.filter(item => item.id !== post.id);
const index = checkinDataList.value.findIndex(item => item.id === post.id);
if (index > -1) {
checkinDataList.value.splice(index, 1);
checkinDataList.value.splice(index, 1);
}
// 检查是否还可以打卡
const current_date = route.query.date;
if (current_date) {
getTaskDetail(dayjs(current_date).format('YYYY-MM'));
getTaskDetail(dayjs(current_date).format('YYYY-MM'));
} else {
getTaskDetail(dayjs().format('YYYY-MM'));
getTaskDetail(dayjs().format('YYYY-MM'));
}
} else {
showErrorToast('删除失败');
......@@ -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;
// 获取当前用户的打卡日期
......@@ -667,56 +644,56 @@ const onLoad = async (date, isUserInitiated) => {
};
const initPage = async (date) => {
// 重置数据
checkinDataList.value = [];
page.value = 0;
finished.value = false;
loading.value = false;
taskDetail.value = {};
is_task_detail_ready.value = false
selectedSubtaskId.value = '';
const current_date = date || route.query.date;
hasUserSelectedDate.value = !!current_date;
if (current_date) {
selectedDate.value = new Date(current_date);
await getTaskDetail(dayjs(current_date).format('YYYY-MM'));
// 确保日历组件已挂载再调用 reset
nextTick(() => {
calendarRef.value?.reset(new Date(current_date));
})
onLoad(current_date, true);
} else {
selectedDate.value = new Date();
await getTaskDetail(dayjs().format('YYYY-MM'));
// 确保日历组件已挂载再调用 reset
nextTick(() => {
calendarRef.value?.reset(new Date());
})
onLoad(null, false);
}
}
// 重置数据
checkinDataList.value = [];
page.value = 0;
finished.value = false;
loading.value = false;
taskDetail.value = {};
is_task_detail_ready.value = false
selectedSubtaskId.value = '';
onMounted(async () => {
// 记录当前的taskId
lastTaskId.value = route.query.id;
await initPage(route.query.date);
const current_date = date || route.query.date;
hasUserSelectedDate.value = !!current_date;
if (current_date) {
selectedDate.value = new Date(current_date);
await getTaskDetail(dayjs(current_date).format('YYYY-MM'));
// 确保日历组件已挂载再调用 reset
nextTick(() => {
calendarRef.value?.reset(new Date(current_date));
})
onLoad(current_date, true);
} else {
selectedDate.value = new Date();
await getTaskDetail(dayjs().format('YYYY-MM'));
// 确保日历组件已挂载再调用 reset
nextTick(() => {
isInitializing.value = false;
calendarRef.value?.reset(new Date());
})
onLoad(null, false);
}
}
// 获取作品类型数据
try {
const { code, data } = await getTeacherFindSettingsAPI();
if (code === 1 && data.task_attachment_type) {
attachmentTypeOptions.value = Object.entries(data.task_attachment_type).map(([key, value]) => ({
key,
value
}));
}
} catch (error) {
console.error('获取作品类型数据失败:', error);
onMounted(async () => {
// 记录当前的taskId
lastTaskId.value = route.query.id;
await initPage(route.query.date);
nextTick(() => {
isInitializing.value = false;
})
// 获取作品类型数据
try {
const { code, data } = await getTeacherFindSettingsAPI();
if (code === 1 && data.task_attachment_type) {
attachmentTypeOptions.value = Object.entries(data.task_attachment_type).map(([key, value]) => ({
key,
value
}));
}
} catch (error) {
console.error('获取作品类型数据失败:', error);
}
})
/**
......@@ -725,187 +702,312 @@ onMounted(async () => {
* 其他情况离开页面都清除筛选缓存
*/
onBeforeRouteLeave((to, from) => {
// 检查目标路径是否是打卡详情页
if (!to.path.startsWith('/checkin/detail')) {
sessionStorage.removeItem('collapsible_calendar_filter_state')
// 关键:清除 lastTaskId,这样下次即使同ID进入,也会被视为新任务触发刷新
lastTaskId.value = ''
}
// 检查目标路径是否是打卡详情页
if (!to.path.startsWith('/checkin/detail')) {
sessionStorage.removeItem('collapsible_calendar_filter_state')
// 关键:清除 lastTaskId,这样下次即使同ID进入,也会被视为新任务触发刷新
lastTaskId.value = ''
}
})
const formatData = (data) => {
let formattedData = [];
formattedData = data?.checkin_list.map((item, index) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
}
})
}
return {
id: item.id,
task_id: item.task_id,
user: {
name: item.username,
avatar: item.avatar,
time: item.created_time_desc,
is_makeup: item.is_makeup,
},
content: item.note,
images,
videoList,
audio,
isPlaying: false,
likes: item.like_count,
is_liked: item.is_like,
is_my: item.is_my,
file_type: item.file_type,
subtask_title: item.subtask_title,
subtask_id: item.subtask_id,
gratitude_count: item.gratitude_count,
gratitude_form_list: item.gratitude_form_list,
}
})
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
}
})
}
return {
id: item.id,
task_id: item.task_id,
user: {
name: item.username,
avatar: item.avatar,
time: item.created_time_desc,
is_makeup: item.is_makeup,
},
content: item.note,
images,
videoList,
audio,
isPlaying: false,
likes: item.like_count,
is_liked: item.is_like,
is_my: item.is_my,
file_type: item.file_type,
subtask_title: item.subtask_title,
subtask_id: item.subtask_id,
gratitude_count: item.gratitude_count,
gratitude_form_list: item.gratitude_form_list,
}
})
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 () => {
const current_date = route.query.date
checkinDataList.value = []
page.value = 0
finished.value = false
loading.value = true
await getTaskDetail(dayjs(current_date || dayjs()).format('YYYY-MM'))
await onLoad(current_date, hasUserSelectedDate.value)
const current_date = route.query.date
checkinDataList.value = []
page.value = 0
finished.value = false
loading.value = true
await getTaskDetail(dayjs(current_date || dayjs()).format('YYYY-MM'))
await onLoad(current_date, hasUserSelectedDate.value)
}
onActivated(async () => {
// 检查任务ID是否变化
// 注意:route.query.id 可能是数字或字符串,统一转为字符串比较
const currentId = String(route.query.id || '')
const lastId = String(lastTaskId.value || '')
if (currentId && currentId !== lastId) {
lastTaskId.value = currentId;
// 如果任务ID变化,强制刷新整个页面数据
await initPage(route.query.date);
// 滚动到顶部
window.scrollTo(0, 0);
return;
}
// 注释:开启调试日志方式(二选一即可)
// 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,
})
// 恢复滚动位置
if (savedScrollTop.value > 0) {
setTimeout(() => {
window.scrollTo(0, savedScrollTop.value)
}, 0)
// 检查任务ID是否变化
// 注意:route.query.id 可能是数字或字符串,统一转为字符串比较
const currentId = String(route.query.id || '')
const lastId = String(lastTaskId.value || '')
if (currentId && currentId !== lastId) {
lastTaskId.value = currentId;
// 如果任务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);
} 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;
}
return;
}
// 检查是否有数据刷新标记
const refreshType = sessionStorage.getItem('checkin_refresh_flag')
const refreshId = sessionStorage.getItem('checkin_refresh_id')
if (refreshType) {
// 清除标记
sessionStorage.removeItem('checkin_refresh_flag')
sessionStorage.removeItem('checkin_refresh_id')
let has_refreshed = false
if (refreshId) {
try {
// 获取最新的打卡信息
const { code, data } = await getUploadTaskInfoAPI({ i: refreshId })
if (code === 1 && data) {
// 构造伪造的 data 对象以适配 formatData
const mockData = { checkin_list: [data] }
const formattedList = formatData(mockData)
if (formattedList && formattedList.length > 0) {
const formattedItem = formattedList[0]
if (refreshType === 'edit') {
// 编辑模式:更新列表中的对应项
const index = checkinDataList.value.findIndex(item => item.id == refreshId)
if (index > -1) {
checkinDataList.value.splice(index, 1, formattedItem)
}
has_refreshed = true
} else if (refreshType === 'add') {
// 新增模式:添加到列表顶部,且去重
const exists = checkinDataList.value.some(item => item.id == formattedItem.id)
if (!exists) {
checkinDataList.value.unshift(formattedItem)
}
// 更新统计数据
const current_date = route.query.date || dayjs().format('YYYY-MM-DD');
getTaskDetail(dayjs(current_date).format('YYYY-MM'));
has_refreshed = true
}
}
}
} catch (error) {
console.error('刷新打卡数据失败:', error)
// 检查是否有数据刷新标记
const refreshType = sessionStorage.getItem('checkin_refresh_flag')
const refreshId = sessionStorage.getItem('checkin_refresh_id')
if (refreshType) {
// 清除标记
sessionStorage.removeItem('checkin_refresh_flag')
sessionStorage.removeItem('checkin_refresh_id')
let has_refreshed = false
if (refreshId) {
try {
// 获取最新的打卡信息
const { code, data } = await getUploadTaskInfoAPI({ i: refreshId })
if (code === 1 && data) {
// 构造伪造的 data 对象以适配 formatData
const mockData = { checkin_list: [data] }
const formattedList = formatData(mockData)
if (formattedList && formattedList.length > 0) {
const formattedItem = formattedList[0]
if (refreshType === 'edit') {
// 编辑模式:更新列表中的对应项
const index = checkinDataList.value.findIndex(item => item.id == refreshId)
if (index > -1) {
checkinDataList.value.splice(index, 1, formattedItem)
}
has_refreshed = true
} else if (refreshType === 'add') {
// 新增模式:添加到列表顶部,且去重
const exists = checkinDataList.value.some(item => item.id == formattedItem.id)
if (!exists) {
checkinDataList.value.unshift(formattedItem)
}
// 更新统计数据
const current_date = route.query.date || dayjs().format('YYYY-MM-DD');
getTaskDetail(dayjs(current_date).format('YYYY-MM'));
has_refreshed = true
}
}
}
} catch (error) {
console.error('刷新打卡数据失败:', error)
}
}
if (!has_refreshed) {
try {
await refresh_checkin_list()
} catch (error) {
console.error('回退刷新打卡列表失败:', error)
}
}
if (!has_refreshed) {
try {
await refresh_checkin_list()
} catch (error) {
console.error('回退刷新打卡列表失败:', error)
}
}
}
// 注释:读取当前保存的滚动状态,用于排查“没有执行/状态被清空”的问题
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>
<style lang="less">
// 固定日历样式
.fixed-calendar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff); // 与AppLayout保持一致的渐变背景
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff); // 与AppLayout保持一致的渐变背景
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
// 可滚动内容区域样式
.scrollable-content {
margin-top: v-bind('calendarHeight + "px"'); // 动态计算日历高度
padding-top: 1rem;
padding-bottom: 6rem; // 添加底部padding,避免内容被底部按钮遮挡
// 移除固定高度和overflow,让AppLayout处理滚动
margin-top: v-bind('calendarHeight + "px"'); // 动态计算日历高度
padding-top: 1rem;
padding-bottom: 6rem; // 添加底部padding,避免内容被底部按钮遮挡
// 移除固定高度和overflow,让AppLayout处理滚动
}
.van-back-top {
......@@ -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;
}
......@@ -1092,16 +1193,16 @@ onActivated(async () => {
}
:deep(.van-calendar__footer) {
display: none;
display: none;
}
/* 禁用未来日期的样式 */
:deep(.calendar-disabled) {
color: #c8c9cc !important;
cursor: not-allowed !important;
color: #c8c9cc !important;
cursor: not-allowed !important;
}
:deep(.calendar-disabled .van-calendar__day-text) {
color: #c8c9cc !important;
color: #c8c9cc !important;
}
</style>
......