From cc4c20e77b6988576ce9b862dcc9369823617ad8 Mon Sep 17 00:00:00 2001 From: cat-shark <1716967236@qq.com> Date: Sun, 13 Sep 2026 19:06:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A6=96=E9=A1=B5=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=9D=A1=E6=94=B9=E4=B8=BA=E6=81=92=E9=80=9F=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E6=8B=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/components/Welcome.spec.ts | 234 +++++++++++++++++++++++ src/__tests__/utils/autoScroll.spec.ts | 117 ++++++++++++ src/components/Welcome.vue | 159 ++++++++++++--- src/utils/autoScroll.ts | 102 ++++++++++ 4 files changed, 587 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/components/Welcome.spec.ts create mode 100644 src/__tests__/utils/autoScroll.spec.ts create mode 100644 src/utils/autoScroll.ts diff --git a/src/__tests__/components/Welcome.spec.ts b/src/__tests__/components/Welcome.spec.ts new file mode 100644 index 0000000..8634d00 --- /dev/null +++ b/src/__tests__/components/Welcome.spec.ts @@ -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: '
' } }, + { path: '/welcome', component: { template: '
' } }, + { path: '/study', component: { template: '
' } }, + { path: '/review', component: { template: '
' } }, + ], +}) + +/** jsdom 不实现 Web Animations API,用可控桩替代 */ +interface AnimStub { + keyframes: unknown + options: { duration: number; iterations: number; easing: string } + currentTime: number + paused: boolean + cancel: ReturnType + pause: ReturnType + play: ReturnType +} + +let animations: AnimStub[] = [] +let animateSpy: ReturnType + +/** + * 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() + }) +}) diff --git a/src/__tests__/utils/autoScroll.spec.ts b/src/__tests__/utils/autoScroll.spec.ts new file mode 100644 index 0000000..43e5fe6 --- /dev/null +++ b/src/__tests__/utils/autoScroll.spec.ts @@ -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) + }) +}) diff --git a/src/components/Welcome.vue b/src/components/Welcome.vue index d47a803..f2e4946 100644 --- a/src/components/Welcome.vue +++ b/src/components/Welcome.vue @@ -1,12 +1,21 @@ @@ -181,10 +283,15 @@ onBeforeUnmount(() => {
{ v-for="(chip, index) in duplicatedChips" :key="index" class="review-chip fragment-chip" - @click="openRecallCard(chip)" + @click="handleChipClick(chip, $event)" > {{ chip.taskName }} {{ chip.content }} @@ -359,6 +466,8 @@ onBeforeUnmount(() => { .review-scroll-track { overflow: hidden; + /* 限制横向手势交给脚本处理,避免拖动时页面跟着晃 */ + touch-action: pan-y; 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); } diff --git a/src/utils/autoScroll.ts b/src/utils/autoScroll.ts new file mode 100644 index 0000000..73884db --- /dev/null +++ b/src/utils/autoScroll.ts @@ -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); +}