fix: 首页滚动条改为恒速并补移动端拖动
This commit is contained in:
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import Welcome from '@/components/Welcome.vue'
|
||||||
|
import { getReviewFeed } from '@/api/review'
|
||||||
|
import { BASE_SPEED_PX_PER_SEC, NARROW_SPEED_FACTOR } from '@/utils/autoScroll'
|
||||||
|
|
||||||
|
vi.mock('@/api/review', () => ({
|
||||||
|
getReviewFeed: vi.fn(),
|
||||||
|
getTaskReview: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const makeFragment = (id: number) => ({
|
||||||
|
id,
|
||||||
|
sessionNum: `SESSION_${id}`,
|
||||||
|
taskName: `任务${id}`,
|
||||||
|
taskNum: `T${id}`,
|
||||||
|
sourceType: 'FRAGMENT',
|
||||||
|
content: `残片内容${id}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/welcome', component: { template: '<div />' } },
|
||||||
|
{ path: '/study', component: { template: '<div />' } },
|
||||||
|
{ path: '/review', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** jsdom 不实现 Web Animations API,用可控桩替代 */
|
||||||
|
interface AnimStub {
|
||||||
|
keyframes: unknown
|
||||||
|
options: { duration: number; iterations: number; easing: string }
|
||||||
|
currentTime: number
|
||||||
|
paused: boolean
|
||||||
|
cancel: ReturnType<typeof vi.fn>
|
||||||
|
pause: ReturnType<typeof vi.fn>
|
||||||
|
play: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
let animations: AnimStub[] = []
|
||||||
|
let animateSpy: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom 不做布局,用宽度桩模拟渲染结果:
|
||||||
|
* scrollWidth = 单份内容宽 × 当前已渲染份数(由 DOM 里的 chip 数量推得),
|
||||||
|
* clientWidth = 轨道宽度。
|
||||||
|
*/
|
||||||
|
const stubLayout = (perCopyWidth: number, trackWidth: number) => {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
if (!this.classList?.contains('review-scroll-content')) return 0
|
||||||
|
const renderedChips = this.querySelectorAll('.review-chip').length
|
||||||
|
return perCopyWidth * (renderedChips / SOURCE_CHIP_COUNT)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return this.classList?.contains('review-scroll-track') ? trackWidth : 0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mock 数据里的残留条数,用于把“份数”换算回“条数” */
|
||||||
|
const SOURCE_CHIP_COUNT = 2
|
||||||
|
/** 从渲染结果反推当前份数 */
|
||||||
|
const renderedRepeatCount = (wrapper: { findAll: (s: string) => unknown[] }) =>
|
||||||
|
wrapper.findAll('.review-chip').length / SOURCE_CHIP_COUNT
|
||||||
|
|
||||||
|
const mountWelcome = async () => {
|
||||||
|
const wrapper = mount(Welcome, { global: { plugins: [router, ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
animations = []
|
||||||
|
animateSpy = vi.fn((keyframes: unknown, options: AnimStub['options']) => {
|
||||||
|
const anim: AnimStub = {
|
||||||
|
keyframes,
|
||||||
|
options,
|
||||||
|
currentTime: 0,
|
||||||
|
paused: false,
|
||||||
|
cancel: vi.fn(),
|
||||||
|
pause: vi.fn(function (this: AnimStub) { this.paused = true }),
|
||||||
|
play: vi.fn(function (this: AnimStub) { this.paused = false }),
|
||||||
|
}
|
||||||
|
animations.push(anim)
|
||||||
|
return anim as unknown as Animation
|
||||||
|
})
|
||||||
|
Element.prototype.animate = animateSpy as unknown as Element['animate']
|
||||||
|
vi.mocked(getReviewFeed).mockResolvedValue({ code: 200, data: [makeFragment(1), makeFragment(2)] })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// @ts-expect-error 清理原型上的自定义布局桩
|
||||||
|
delete HTMLElement.prototype.scrollWidth
|
||||||
|
// @ts-expect-error 清理原型上的自定义布局桩
|
||||||
|
delete HTMLElement.prototype.clientWidth
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Welcome 首页滚动条', () => {
|
||||||
|
it('内容超过一屏时按基准速度建立动画,时长由恒速换算', async () => {
|
||||||
|
stubLayout(4000, 1000) // 单份 4000px,60px/s → 66666ms
|
||||||
|
await mountWelcome()
|
||||||
|
|
||||||
|
expect(animateSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const options = animations[0].options
|
||||||
|
expect(options.iterations).toBe(Infinity)
|
||||||
|
expect(options.easing).toBe('linear')
|
||||||
|
expect(options.duration).toBeCloseTo((4000 / BASE_SPEED_PX_PER_SEC) * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('速度与内容量无关:内容翻倍只让时长翻倍', async () => {
|
||||||
|
stubLayout(4000, 1000)
|
||||||
|
await mountWelcome()
|
||||||
|
const few = animations[0].options.duration
|
||||||
|
|
||||||
|
animations = []
|
||||||
|
animateSpy.mockClear()
|
||||||
|
stubLayout(40000, 1000)
|
||||||
|
await mountWelcome()
|
||||||
|
const many = animations[0].options.duration
|
||||||
|
|
||||||
|
expect(many / few).toBeCloseTo(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('内容不足一屏时仍然滚动,且按单份宽度换算时长', async () => {
|
||||||
|
stubLayout(300, 1000) // 单份 300px,远小于轨道
|
||||||
|
await mountWelcome()
|
||||||
|
|
||||||
|
expect(animateSpy).toHaveBeenCalled()
|
||||||
|
expect(animations[animations.length - 1].options.duration)
|
||||||
|
.toBeCloseTo((300 / BASE_SPEED_PX_PER_SEC) * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('内容比轨道窄时补足份数后重建动画,避免循环露白', async () => {
|
||||||
|
stubLayout(300, 1000) // 单份 300px、轨道 1000px → ceil(1000/300)+1 = 5 份
|
||||||
|
const wrapper = await mountWelcome()
|
||||||
|
|
||||||
|
expect(renderedRepeatCount(wrapper)).toBe(5)
|
||||||
|
expect(animateSpy).toHaveBeenCalledTimes(1)
|
||||||
|
// 位移按份数换算:100% / 5 = 20%
|
||||||
|
expect(animations[0].keyframes).toEqual([
|
||||||
|
{ transform: 'translateX(0)' },
|
||||||
|
{ transform: 'translateX(-20%)' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('内容足够宽时保持最少两份,不会反复重建', async () => {
|
||||||
|
stubLayout(4000, 1000)
|
||||||
|
const wrapper = await mountWelcome()
|
||||||
|
|
||||||
|
expect(renderedRepeatCount(wrapper)).toBe(2)
|
||||||
|
expect(animateSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('窄屏(手机)按系数降速', async () => {
|
||||||
|
stubLayout(4000, 375)
|
||||||
|
await mountWelcome()
|
||||||
|
|
||||||
|
const speed = 4000 / (animations[0].options.duration / 1000)
|
||||||
|
expect(speed).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('滚轮按速度换算手动位移,并夹在一轮时长内', async () => {
|
||||||
|
stubLayout(4000, 1000)
|
||||||
|
const wrapper = await mountWelcome()
|
||||||
|
const track = wrapper.find('.review-scroll-track')
|
||||||
|
|
||||||
|
await track.trigger('wheel', { deltaY: BASE_SPEED_PX_PER_SEC, deltaMode: 0 })
|
||||||
|
// 滚动 60px = 1 秒动画时间
|
||||||
|
expect(animations[0].currentTime).toBeCloseTo(1000)
|
||||||
|
|
||||||
|
await track.trigger('wheel', { deltaY: -100000, deltaMode: 0 })
|
||||||
|
expect(animations[0].currentTime).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('手指左右拖动共用同一套换算,拖动后恢复播放', async () => {
|
||||||
|
stubLayout(4000, 1000)
|
||||||
|
const wrapper = await mountWelcome()
|
||||||
|
const track = wrapper.find('.review-scroll-track')
|
||||||
|
|
||||||
|
await track.trigger('touchstart', { touches: [{ clientX: 300 }] })
|
||||||
|
expect(animations[0].paused).toBe(true)
|
||||||
|
|
||||||
|
await track.trigger('touchmove', { touches: [{ clientX: 360 }] })
|
||||||
|
expect(animations[0].currentTime).toBeCloseTo(1000)
|
||||||
|
|
||||||
|
await track.trigger('touchend')
|
||||||
|
expect(animations[0].paused).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拖动后松开不会误触打开回忆卡片', async () => {
|
||||||
|
stubLayout(4000, 1000)
|
||||||
|
const wrapper = await mountWelcome()
|
||||||
|
const track = wrapper.find('.review-scroll-track')
|
||||||
|
|
||||||
|
await track.trigger('touchstart', { touches: [{ clientX: 300 }] })
|
||||||
|
await track.trigger('touchmove', { touches: [{ clientX: 400 }] })
|
||||||
|
await wrapper.find('.review-chip').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.recall-question').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击残片(未拖动)会打开回忆卡片', async () => {
|
||||||
|
stubLayout(4000, 1000)
|
||||||
|
const wrapper = await mountWelcome()
|
||||||
|
|
||||||
|
await wrapper.find('.review-chip').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.recall-question').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.recall-snippet').text()).toContain('残片内容1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('卸载时取消动画并移除窗口监听', async () => {
|
||||||
|
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||||
|
stubLayout(4000, 1000)
|
||||||
|
const wrapper = await mountWelcome()
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
expect(animations[0].cancel).toHaveBeenCalled()
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||||
|
removeSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
BASE_SPEED_PX_PER_SEC,
|
||||||
|
MAX_REPEAT_COUNT,
|
||||||
|
MAX_SPEED_PX_PER_SEC,
|
||||||
|
MIN_REPEAT_COUNT,
|
||||||
|
MIN_SPEED_PX_PER_SEC,
|
||||||
|
NARROW_SPEED_FACTOR,
|
||||||
|
clampAutoScrollTime,
|
||||||
|
getRepeatCount,
|
||||||
|
getScrollSpeedPxPerSec,
|
||||||
|
measureAutoScroll,
|
||||||
|
pxToTimeOffset,
|
||||||
|
} from '@/utils/autoScroll'
|
||||||
|
|
||||||
|
const DESKTOP = 1440
|
||||||
|
|
||||||
|
describe('getScrollSpeedPxPerSec', () => {
|
||||||
|
it('桌面端使用基准速度', () => {
|
||||||
|
expect(getScrollSpeedPxPerSec(DESKTOP)).toBe(BASE_SPEED_PX_PER_SEC)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('正好 768px 属于手机端,769px 回到桌面速度', () => {
|
||||||
|
expect(getScrollSpeedPxPerSec(768)).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
|
||||||
|
expect(getScrollSpeedPxPerSec(769)).toBe(BASE_SPEED_PX_PER_SEC)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('手机端是桌面速度的固定倍数', () => {
|
||||||
|
const narrow = getScrollSpeedPxPerSec(375)
|
||||||
|
expect(narrow).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
|
||||||
|
expect(narrow).toBeLessThan(getScrollSpeedPxPerSec(DESKTOP))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('桌面速度始终夹在上下限内', () => {
|
||||||
|
;[0, 769, 1024, 1440, 2560].forEach(width => {
|
||||||
|
const speed = getScrollSpeedPxPerSec(width)
|
||||||
|
expect(speed).toBeGreaterThanOrEqual(MIN_SPEED_PX_PER_SEC)
|
||||||
|
expect(speed).toBeLessThanOrEqual(MAX_SPEED_PX_PER_SEC)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('measureAutoScroll', () => {
|
||||||
|
it('一轮距离为单份内容宽度,时长由恒速换算', () => {
|
||||||
|
// 单份 2000px;60px/s → 33.3s
|
||||||
|
const metrics = measureAutoScroll(2000, DESKTOP)
|
||||||
|
expect(metrics).not.toBeNull()
|
||||||
|
expect(metrics!.loopWidth).toBe(2000)
|
||||||
|
expect(metrics!.speed).toBe(BASE_SPEED_PX_PER_SEC)
|
||||||
|
expect(metrics!.duration).toBeCloseTo((2000 / BASE_SPEED_PX_PER_SEC) * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('速度恒定:内容量翻倍只改变时长,不改变速度', () => {
|
||||||
|
const few = measureAutoScroll(2000, DESKTOP)!
|
||||||
|
const many = measureAutoScroll(20000, DESKTOP)!
|
||||||
|
expect(few.speed).toBe(many.speed)
|
||||||
|
expect(many.duration / few.duration).toBeCloseTo(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('内容为空或宽度异常时返回 null', () => {
|
||||||
|
expect(measureAutoScroll(0, DESKTOP)).toBeNull()
|
||||||
|
expect(measureAutoScroll(-100, DESKTOP)).toBeNull()
|
||||||
|
expect(measureAutoScroll(Number.NaN, DESKTOP)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRepeatCount', () => {
|
||||||
|
it('内容再多也用最少两份', () => {
|
||||||
|
expect(getRepeatCount(5000, 1000)).toBe(MIN_REPEAT_COUNT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('内容比轨道窄时补足到能铺满并预留衔接份', () => {
|
||||||
|
// 单份 300px、轨道 1000px:ceil(1000/300)+1 = 5
|
||||||
|
expect(getRepeatCount(300, 1000)).toBe(5)
|
||||||
|
// 刚好铺满也要多一份用于衔接
|
||||||
|
expect(getRepeatCount(1000, 1000)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('份数不超过上限', () => {
|
||||||
|
expect(getRepeatCount(1, 100000)).toBe(MAX_REPEAT_COUNT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('宽度非法时回退到最少份数', () => {
|
||||||
|
expect(getRepeatCount(0, 1000)).toBe(MIN_REPEAT_COUNT)
|
||||||
|
expect(getRepeatCount(Number.NaN, 1000)).toBe(MIN_REPEAT_COUNT)
|
||||||
|
expect(getRepeatCount(300, 0)).toBe(MIN_REPEAT_COUNT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('measureAutoScroll 会带上份数', () => {
|
||||||
|
expect(measureAutoScroll(300, 1000)!.repeatCount).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pxToTimeOffset', () => {
|
||||||
|
it('按速度把位移换算成时间', () => {
|
||||||
|
expect(pxToTimeOffset(BASE_SPEED_PX_PER_SEC, BASE_SPEED_PX_PER_SEC)).toBe(1000)
|
||||||
|
expect(pxToTimeOffset(30, 60)).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('速度为 0 或参数非法时返回 0', () => {
|
||||||
|
expect(pxToTimeOffset(100, 0)).toBe(0)
|
||||||
|
expect(pxToTimeOffset(Number.NaN, 60)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clampAutoScrollTime', () => {
|
||||||
|
it('夹在 0 与一轮时长之间', () => {
|
||||||
|
expect(clampAutoScrollTime(-500, 1000)).toBe(0)
|
||||||
|
expect(clampAutoScrollTime(1500, 1000)).toBe(1000)
|
||||||
|
expect(clampAutoScrollTime(400, 1000)).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('参数非法时归零', () => {
|
||||||
|
expect(clampAutoScrollTime(Number.NaN, 1000)).toBe(0)
|
||||||
|
expect(clampAutoScrollTime(100, 0)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
+134
-25
@@ -1,12 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import { getReviewFeed, getTaskReview, type ReviewFeedItem } from "@/api/review";
|
import { getReviewFeed, getTaskReview, type ReviewFeedItem } from "@/api/review";
|
||||||
|
import {
|
||||||
|
clampAutoScrollTime,
|
||||||
|
measureAutoScroll,
|
||||||
|
pxToTimeOffset,
|
||||||
|
type AutoScrollMetrics,
|
||||||
|
} from "@/utils/autoScroll";
|
||||||
|
|
||||||
const reviewItems = ref<ReviewFeedItem[]>([]);
|
const reviewItems = ref<ReviewFeedItem[]>([]);
|
||||||
const contentRef = ref<HTMLElement | null>(null);
|
const contentRef = ref<HTMLElement | null>(null);
|
||||||
const AUTO_SCROLL_DURATION = 150_000;
|
const trackRef = ref<HTMLElement | null>(null);
|
||||||
let autoScroll: Animation | null = null;
|
let autoScroll: Animation | null = null;
|
||||||
|
let autoScrollMetrics: AutoScrollMetrics | null = null;
|
||||||
|
/** 当前渲染的内容份数:内容比轨道窄时要多铺几份,否则循环时会露白 */
|
||||||
|
const repeatCount = ref(2);
|
||||||
|
|
||||||
interface FragmentChip {
|
interface FragmentChip {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -32,8 +41,10 @@ const fragmentChips = computed<FragmentChip[]>(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 复制一份实现无缝循环
|
// 复制多份实现无缝循环,份数按轨道宽度动态补足
|
||||||
const duplicatedChips = computed(() => [...fragmentChips.value, ...fragmentChips.value]);
|
const duplicatedChips = computed(() =>
|
||||||
|
Array.from({ length: repeatCount.value }, () => fragmentChips.value).flat(),
|
||||||
|
);
|
||||||
|
|
||||||
const loadReviewFeed = async () => {
|
const loadReviewFeed = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -41,6 +52,10 @@ const loadReviewFeed = async () => {
|
|||||||
reviewItems.value = res?.data || [];
|
reviewItems.value = res?.data || [];
|
||||||
} catch {
|
} catch {
|
||||||
// feed 加载失败不影响页面主体功能
|
// feed 加载失败不影响页面主体功能
|
||||||
|
} finally {
|
||||||
|
// 残片宽度依赖真实内容,必须等 DOM 更新后再按实际宽度重建动画
|
||||||
|
await nextTick();
|
||||||
|
startAutoScroll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,27 +139,117 @@ const startReview = () => {
|
|||||||
router.push("/review");
|
router.push("/review");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTrackWheel = (event: WheelEvent) => {
|
/** 按当前内容宽度与轨道宽度(重新)建立动画 */
|
||||||
const anim = autoScroll;
|
const startAutoScroll = () => {
|
||||||
const content = contentRef.value;
|
const content = contentRef.value;
|
||||||
if (!anim || !content) return;
|
const track = trackRef.value;
|
||||||
|
if (!content || !track) return;
|
||||||
|
|
||||||
anim.pause();
|
// 保留手动滚动后的位置:重建动画前把当前进度换成比例
|
||||||
|
const elapsed = autoScroll && typeof autoScroll.currentTime === "number"
|
||||||
|
? autoScroll.currentTime
|
||||||
|
: 0;
|
||||||
|
const prevDuration = autoScrollMetrics?.duration ?? 0;
|
||||||
|
const progress = prevDuration > 0 ? elapsed / prevDuration : 0;
|
||||||
|
autoScroll?.cancel();
|
||||||
|
autoScroll = null;
|
||||||
|
autoScrollMetrics = null;
|
||||||
|
|
||||||
|
// 渲染总宽 / 当前份数 = 单份内容的实际宽度
|
||||||
|
const singleCopyWidth = content.scrollWidth / Math.max(repeatCount.value, 1);
|
||||||
|
const metrics = measureAutoScroll(singleCopyWidth, track.clientWidth);
|
||||||
|
if (!metrics) return;
|
||||||
|
|
||||||
|
// 份数不足时先补足再重新测量,下一轮才是准确的循环宽度
|
||||||
|
if (metrics.repeatCount !== repeatCount.value) {
|
||||||
|
repeatCount.value = metrics.repeatCount;
|
||||||
|
nextTick(() => startAutoScroll());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScrollMetrics = metrics;
|
||||||
|
const anim = content.animate(
|
||||||
|
[
|
||||||
|
{ transform: "translateX(0)" },
|
||||||
|
{ transform: `translateX(-${100 / repeatCount.value}%)` },
|
||||||
|
],
|
||||||
|
{ duration: metrics.duration, iterations: Infinity, easing: "linear" },
|
||||||
|
);
|
||||||
|
anim.currentTime = progress * metrics.duration;
|
||||||
|
autoScroll = anim;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 滚轮与手指拖动共用的手动位移 */
|
||||||
|
const scrollByPixels = (deltaPx: number) => {
|
||||||
|
const anim = autoScroll;
|
||||||
|
const metrics = autoScrollMetrics;
|
||||||
|
if (!anim || !metrics) return;
|
||||||
|
const current = anim.currentTime;
|
||||||
|
if (typeof current !== "number") return;
|
||||||
|
anim.currentTime = clampAutoScrollTime(
|
||||||
|
current + pxToTimeOffset(deltaPx, metrics.speed),
|
||||||
|
metrics.duration,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrackWheel = (event: WheelEvent) => {
|
||||||
|
if (!autoScrollMetrics) return;
|
||||||
|
autoScroll?.pause();
|
||||||
|
|
||||||
const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||||
const delta = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? rawDelta * 16 : rawDelta;
|
const delta = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? rawDelta * 16 : rawDelta;
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
|
scrollByPixels(delta);
|
||||||
|
};
|
||||||
|
|
||||||
// 一屏内容的宽度对应一轮自动滚动
|
let touchLastX = 0;
|
||||||
const loopWidth = content.scrollWidth / 2;
|
let touchMovedDistance = 0;
|
||||||
if (!loopWidth) return;
|
const TOUCH_TAP_THRESHOLD = 6;
|
||||||
|
|
||||||
const current = anim.currentTime;
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
if (typeof current !== "number") return;
|
if (!autoScrollMetrics) return;
|
||||||
anim.currentTime = Math.min(
|
const touch = event.touches[0];
|
||||||
Math.max(current + (delta / loopWidth) * AUTO_SCROLL_DURATION, 0),
|
if (!touch) return;
|
||||||
AUTO_SCROLL_DURATION,
|
touchLastX = touch.clientX;
|
||||||
);
|
touchMovedDistance = 0;
|
||||||
|
autoScroll?.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
if (!autoScrollMetrics) return;
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
const deltaX = touch.clientX - touchLastX;
|
||||||
|
touchLastX = touch.clientX;
|
||||||
|
touchMovedDistance += Math.abs(deltaX);
|
||||||
|
if (!deltaX) return;
|
||||||
|
scrollByPixels(deltaX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
autoScroll?.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 拖动过就吞掉这次 click,避免误触打开回忆卡片 */
|
||||||
|
const handleChipClick = (fragment: FragmentChip, event: MouseEvent) => {
|
||||||
|
if (touchMovedDistance > TOUCH_TAP_THRESHOLD) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
touchMovedDistance = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
touchMovedDistance = 0;
|
||||||
|
openRecallCard(fragment);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 视口跨过断点时速度会变,需重新测量时长并保持当前进度 */
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const handleViewportResize = () => {
|
||||||
|
if (resizeTimer) clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
resizeTimer = null;
|
||||||
|
startAutoScroll();
|
||||||
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pauseAutoScroll = () => {
|
const pauseAutoScroll = () => {
|
||||||
@@ -157,17 +262,14 @@ const resumeAutoScroll = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadReviewFeed();
|
loadReviewFeed();
|
||||||
autoScroll = contentRef.value?.animate(
|
window.addEventListener("resize", handleViewportResize);
|
||||||
[
|
|
||||||
{ transform: "translateX(0)" },
|
|
||||||
{ transform: "translateX(-50%)" },
|
|
||||||
],
|
|
||||||
{ duration: AUTO_SCROLL_DURATION, iterations: Infinity, easing: "linear" },
|
|
||||||
) || null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
autoScroll?.cancel();
|
autoScroll?.cancel();
|
||||||
|
autoScroll = null;
|
||||||
|
window.removeEventListener("resize", handleViewportResize);
|
||||||
|
if (resizeTimer) clearTimeout(resizeTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -181,10 +283,15 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
ref="trackRef"
|
||||||
class="review-scroll-track"
|
class="review-scroll-track"
|
||||||
@mouseenter="pauseAutoScroll"
|
@mouseenter="pauseAutoScroll"
|
||||||
@mouseleave="resumeAutoScroll"
|
@mouseleave="resumeAutoScroll"
|
||||||
@wheel.prevent="handleTrackWheel"
|
@wheel.prevent="handleTrackWheel"
|
||||||
|
@touchstart.passive="handleTouchStart"
|
||||||
|
@touchmove.passive="handleTouchMove"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
|
@touchcancel="handleTouchEnd"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref="contentRef"
|
ref="contentRef"
|
||||||
@@ -194,7 +301,7 @@ onBeforeUnmount(() => {
|
|||||||
v-for="(chip, index) in duplicatedChips"
|
v-for="(chip, index) in duplicatedChips"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="review-chip fragment-chip"
|
class="review-chip fragment-chip"
|
||||||
@click="openRecallCard(chip)"
|
@click="handleChipClick(chip, $event)"
|
||||||
>
|
>
|
||||||
<strong class="chip-task">{{ chip.taskName }}</strong>
|
<strong class="chip-task">{{ chip.taskName }}</strong>
|
||||||
<span class="chip-content">{{ chip.content }}</span>
|
<span class="chip-content">{{ chip.content }}</span>
|
||||||
@@ -359,6 +466,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.review-scroll-track {
|
.review-scroll-track {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* 限制横向手势交给脚本处理,避免拖动时页面跟着晃 */
|
||||||
|
touch-action: pan-y;
|
||||||
mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
|
mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
|
||||||
-webkit-mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
|
-webkit-mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 无缝横向跑马灯的恒速换算。
|
||||||
|
*
|
||||||
|
* 滚动内容复制多份,动画从 translateX(0) 走到 translateX(-100%/份数),
|
||||||
|
* 因此一"轮"的距离就是单份内容的宽度。
|
||||||
|
*
|
||||||
|
* 速度恒定(与内容量无关):同样的 px/s 在任何数据量、任何环境下观感一致;
|
||||||
|
* 上下限只作为安全边界,避免以后有人把基准值改坏导致完全不可用。
|
||||||
|
*
|
||||||
|
* 说明:内容不足一屏时仍然滚动——轨道右侧有渐变遮罩,不滚动就看不到后面的内容。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 桌面基准速度:60px/s,一屏 1000px 内容约 17 秒走完 */
|
||||||
|
export const BASE_SPEED_PX_PER_SEC = 60;
|
||||||
|
|
||||||
|
/** 桌面基准速度的安全上限:内容再怪也不超过这个速度 */
|
||||||
|
export const MAX_SPEED_PX_PER_SEC = 85;
|
||||||
|
|
||||||
|
/** 桌面基准速度的安全下限:再慢也不会显得像卡住了 */
|
||||||
|
export const MIN_SPEED_PX_PER_SEC = 45;
|
||||||
|
|
||||||
|
/** 窄屏(手机)相对桌面的速度系数:视口窄,同 px/s 的视觉速度更快 */
|
||||||
|
export const NARROW_SPEED_FACTOR = 0.6;
|
||||||
|
|
||||||
|
/** 移动端断点,与仓库样式约定保持一致 */
|
||||||
|
export const NARROW_MAX_WIDTH = 768;
|
||||||
|
|
||||||
|
/** 最少复制份数:一份用于展示,一份用于无缝衔接 */
|
||||||
|
export const MIN_REPEAT_COUNT = 2;
|
||||||
|
|
||||||
|
/** 复制份数上限,避免内容异常时渲染过多节点 */
|
||||||
|
export const MAX_REPEAT_COUNT = 24;
|
||||||
|
|
||||||
|
export interface AutoScrollMetrics {
|
||||||
|
/** 一轮滚动距离(px),即单份内容的宽度 */
|
||||||
|
loopWidth: number;
|
||||||
|
/** 轨道可视宽度(px) */
|
||||||
|
viewportWidth: number;
|
||||||
|
/** 实际使用速度(px/s) */
|
||||||
|
speed: number;
|
||||||
|
/** 一轮动画时长(ms) */
|
||||||
|
duration: number;
|
||||||
|
/** 需要渲染的内容份数 */
|
||||||
|
repeatCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按视口宽度取速度(px/s)。
|
||||||
|
* 上下限只约束桌面基准速度(防止基准值被改坏),移动端系数在其后生效,
|
||||||
|
* 这样手机端才是真正的桌面 0.6 倍。
|
||||||
|
*/
|
||||||
|
export function getScrollSpeedPxPerSec(viewportWidth: number): number {
|
||||||
|
const baseSpeed = Math.min(Math.max(BASE_SPEED_PX_PER_SEC, MIN_SPEED_PX_PER_SEC), MAX_SPEED_PX_PER_SEC);
|
||||||
|
const isNarrow = viewportWidth > 0 && viewportWidth <= NARROW_MAX_WIDTH;
|
||||||
|
return baseSpeed * (isNarrow ? NARROW_SPEED_FACTOR : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需要渲染多少份内容。
|
||||||
|
* 动画把内容向前推一份的宽度,若总宽不够,循环后半段轨道右侧会露白,
|
||||||
|
* 所以至少要能铺满「可视宽度 + 无缝衔接的那一份」。
|
||||||
|
*/
|
||||||
|
export function getRepeatCount(singleCopyWidth: number, viewportWidth: number): number {
|
||||||
|
if (!Number.isFinite(singleCopyWidth) || singleCopyWidth <= 0) return MIN_REPEAT_COUNT;
|
||||||
|
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) return MIN_REPEAT_COUNT;
|
||||||
|
const needed = Math.ceil(viewportWidth / singleCopyWidth) + 1;
|
||||||
|
return Math.min(Math.max(needed, MIN_REPEAT_COUNT), MAX_REPEAT_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算一轮滚动的距离、速度与时长。
|
||||||
|
* `singleCopyWidth` 是单份内容宽度(调用方按实际用量总宽折算)。
|
||||||
|
* 无法计算时返回 null,调用方据此不启动动画。
|
||||||
|
*/
|
||||||
|
export function measureAutoScroll(
|
||||||
|
singleCopyWidth: number,
|
||||||
|
viewportWidth: number,
|
||||||
|
): AutoScrollMetrics | null {
|
||||||
|
const speed = getScrollSpeedPxPerSec(viewportWidth);
|
||||||
|
if (!Number.isFinite(singleCopyWidth) || singleCopyWidth <= 0) return null;
|
||||||
|
if (!Number.isFinite(speed) || speed <= 0) return null;
|
||||||
|
return {
|
||||||
|
loopWidth: singleCopyWidth,
|
||||||
|
viewportWidth,
|
||||||
|
speed,
|
||||||
|
duration: (singleCopyWidth / speed) * 1000,
|
||||||
|
repeatCount: getRepeatCount(singleCopyWidth, viewportWidth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把位移(px)换算成动画时间(ms),滚轮与手指拖动共用同一套换算 */
|
||||||
|
export function pxToTimeOffset(deltaPx: number, speed: number): number {
|
||||||
|
if (!Number.isFinite(deltaPx) || !Number.isFinite(speed) || speed <= 0) return 0;
|
||||||
|
return (deltaPx / speed) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把动画时间夹在 [0, duration] 内,配合 iterations: Infinity 保持循环连续 */
|
||||||
|
export function clampAutoScrollTime(time: number, duration: number): number {
|
||||||
|
if (!Number.isFinite(time)) return 0;
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return 0;
|
||||||
|
return Math.min(Math.max(time, 0), duration);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user