feat: 首页滚动条展示学习残片并支持滚轮手动滚动

This commit is contained in:
2026-08-03 22:21:13 +08:00
parent 8d5c9db47f
commit 9bba684fb2
2 changed files with 208 additions and 129 deletions
+40
View File
@@ -31,4 +31,44 @@ test.describe('Welcome page', () => {
await page.locator('.quick-card').nth(1).locator('button').click(); await page.locator('.quick-card').nth(1).locator('button').click();
await expect(page).toHaveURL(/\/review/); await expect(page).toHaveURL(/\/review/);
}); });
test('wheel scrolls review ticker manually while hovering', async ({ page }) => {
const items = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
sessionNum: `S${i + 1}`,
taskNum: `T${i + 1}`,
taskName: `任务 ${i + 1}`,
sourceType: 'FRAGMENT',
content: `${i + 1} 条学习残片内容,用来撑起首页滚动区域。`,
createdTime: `2026-08-01 10:00:${String(i).padStart(2, '0')}`,
}));
await page.route('**/api/review/feed**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, data: items }),
}),
);
await page.goto('/welcome');
const track = page.locator('.review-scroll-track');
const content = page.locator('.review-scroll-content');
await expect(track).toBeVisible();
await track.hover();
const readTime = () =>
content.evaluate((el) => {
const anim = el.getAnimations()[0];
return anim && typeof anim.currentTime === 'number' ? anim.currentTime : -1;
});
const before = await readTime();
await page.mouse.wheel(0, 200);
await expect.poll(readTime).toBeGreaterThan(before);
await page.mouse.move(10, 10);
const after = await readTime();
await expect.poll(readTime).toBeGreaterThan(after);
});
}); });
+165 -126
View File
@@ -1,63 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import router from "@/router"; import router from "@/router";
import { getReviewFeed, type ReviewFeedItem } from "@/api/review"; import { getReviewFeed, getTaskReview, type ReviewFeedItem } from "@/api/review";
const reviewItems = ref<ReviewFeedItem[]>([]); const reviewItems = ref<ReviewFeedItem[]>([]);
const isHovering = ref(false); const contentRef = ref<HTMLElement | null>(null);
const AUTO_SCROLL_DURATION = 150_000;
let autoScroll: Animation | null = null;
interface SessionGroup { interface FragmentChip {
id: number;
sessionNum: string; sessionNum: string;
taskName: string; taskName: string;
taskNum: string; taskNum: string;
content: string; content: string;
hasReport: boolean;
navigateType: string;
navigateId: number;
} }
// 按 sessionNum 分组,一个会话展示为一个 chip const truncateContent = (content: string, maxLength = 120) =>
const sessionGroups = computed<SessionGroup[]>(() => { content.length > maxLength ? content.substring(0, maxLength) + "..." : content;
const map = new Map<string, ReviewFeedItem[]>();
for (const item of reviewItems.value) {
const list = map.get(item.sessionNum) || [];
list.push(item);
map.set(item.sessionNum, list);
}
const groups: SessionGroup[] = []; // 滚动条只展示学习残片,每条残片单独作为 chip
for (const [sessionNum, items] of map) { const fragmentChips = computed<FragmentChip[]>(() =>
const first = items[0]; reviewItems.value
const report = items.find(i => i.sourceType === "REPORT"); .filter(item => item.sourceType === "FRAGMENT")
const fragments = items.filter(i => i.sourceType === "FRAGMENT"); .map(item => ({
id: item.id,
const rawContent = report?.content || fragments.map(f => f.content).join(""); sessionNum: item.sessionNum,
const content = rawContent.length > 120 taskName: item.taskName,
? rawContent.substring(0, 120) + "..." taskNum: item.taskNum,
: rawContent; content: truncateContent(item.content),
})),
groups.push({ );
sessionNum,
taskName: first.taskName,
taskNum: first.taskNum,
content,
hasReport: !!report,
navigateType: report ? "report" : "fragment",
navigateId: report ? report.id : (fragments[0]?.id ?? 0),
});
}
groups.sort((a, b) => {
const aItem = reviewItems.value.find(i => i.sessionNum === a.sessionNum);
const bItem = reviewItems.value.find(i => i.sessionNum === b.sessionNum);
return (bItem?.createdTime || "").localeCompare(aItem?.createdTime || "");
});
return groups;
});
// 复制一份实现无缝循环 // 复制一份实现无缝循环
const duplicatedGroups = computed(() => [...sessionGroups.value, ...sessionGroups.value]); const duplicatedChips = computed(() => [...fragmentChips.value, ...fragmentChips.value]);
const loadReviewFeed = async () => { const loadReviewFeed = async () => {
try { try {
@@ -68,42 +44,67 @@ const loadReviewFeed = async () => {
} }
}; };
// 回忆卡片:点击滚动内容先尝试回忆,再看原文 // 回忆卡片:点击残片先尝试回忆,再展开该会话的报告和其他残片进行对照
const recallCard = ref<{ visible: boolean; group: SessionGroup | null; revealed: boolean }>({ const recallCard = ref<{
visible: boolean;
fragment: FragmentChip | null;
report: ReviewFeedItem | null;
fragments: ReviewFeedItem[];
revealed: boolean;
loadingSession: boolean;
loadError: string;
}>({
visible: false, visible: false,
group: null, fragment: null,
report: null,
fragments: [],
revealed: false, revealed: false,
loadingSession: false,
loadError: "",
}); });
// 当前回忆卡片对应会话的全部记录 const openRecallCard = (fragment: FragmentChip) => {
const recallSessionItems = computed(() => { recallCard.value = {
const group = recallCard.value.group; visible: true,
if (!group) return []; fragment,
return reviewItems.value.filter((i) => i.sessionNum === group.sessionNum); report: null,
}); fragments: [],
revealed: false,
const goToReviewDetail = (group: SessionGroup) => { loadingSession: false,
recallCard.value = { visible: true, group, revealed: false }; loadError: "",
};
}; };
const revealRecallContent = () => { const revealRecallContent = async () => {
const fragment = recallCard.value.fragment;
if (!fragment || recallCard.value.loadingSession || recallCard.value.revealed) return;
if (recallCard.value.report || recallCard.value.fragments.length > 0) {
recallCard.value.revealed = true; recallCard.value.revealed = true;
}; return;
}
const openRecallDetail = () => { recallCard.value.loadingSession = true;
const group = recallCard.value.group; recallCard.value.loadError = "";
if (!group) return; try {
recallCard.value.visible = false; const res = await getTaskReview(fragment.taskNum);
router.push(`/review/detail/${group.navigateType}/${group.navigateId}`); const items = (res?.data || []).filter(item => item.sessionNum === fragment.sessionNum);
recallCard.value.report = items.find(item => item.sourceType === "REPORT") || null;
recallCard.value.fragments = items.filter(item => item.sourceType === "FRAGMENT");
recallCard.value.revealed = true;
} catch {
recallCard.value.loadError = "展开对照内容加载失败,请稍后重试";
} finally {
recallCard.value.loadingSession = false;
}
}; };
/** 从回忆卡片跳转到回忆复习页(先匹配标准导图节点) */ /** 从回忆卡片跳转到回忆复习页(先匹配标准导图节点) */
const goToRecallFromCard = async () => { const goToRecallFromCard = async () => {
const group = recallCard.value.group; const fragment = recallCard.value.fragment;
if (!group) return; if (!fragment) return;
recallCard.value.visible = false; recallCard.value.visible = false;
const tn = group.taskNum; const tn = fragment.taskNum;
const content = group.content || ""; const content = fragment.content || "";
if (tn && content) { if (tn && content) {
try { try {
const { findNode } = await import("@/api/standardMindMap"); const { findNode } = await import("@/api/standardMindMap");
@@ -128,8 +129,50 @@ const startReview = () => {
router.push("/review"); router.push("/review");
}; };
const handleTrackWheel = (event: WheelEvent) => {
const anim = autoScroll;
const content = contentRef.value;
if (!anim || !content) return;
anim.pause();
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;
if (!delta) return;
// 一屏内容的宽度对应一轮自动滚动
const loopWidth = content.scrollWidth / 2;
if (!loopWidth) return;
const current = anim.currentTime;
if (typeof current !== "number") return;
anim.currentTime = Math.min(
Math.max(current + (delta / loopWidth) * AUTO_SCROLL_DURATION, 0),
AUTO_SCROLL_DURATION,
);
};
const pauseAutoScroll = () => {
autoScroll?.pause();
};
const resumeAutoScroll = () => {
autoScroll?.play();
};
onMounted(() => { onMounted(() => {
loadReviewFeed(); loadReviewFeed();
autoScroll = contentRef.value?.animate(
[
{ transform: "translateX(0)" },
{ transform: "translateX(-50%)" },
],
{ duration: AUTO_SCROLL_DURATION, iterations: Infinity, easing: "linear" },
) || null;
});
onBeforeUnmount(() => {
autoScroll?.cancel();
}); });
</script> </script>
@@ -139,26 +182,27 @@ onMounted(() => {
<div class="review-scroll-section surface-card"> <div class="review-scroll-section surface-card">
<div class="review-scroll-header"> <div class="review-scroll-header">
<span class="color-legend"> <span class="color-legend">
<span class="dot dot-report"></span> 学习报告
<span class="dot dot-fragment"></span> 学习残片 <span class="dot dot-fragment"></span> 学习残片
</span> </span>
</div> </div>
<div class="review-scroll-track">
<div <div
class="review-scroll-track"
@mouseenter="pauseAutoScroll"
@mouseleave="resumeAutoScroll"
@wheel.prevent="handleTrackWheel"
>
<div
ref="contentRef"
class="review-scroll-content" class="review-scroll-content"
:class="{ paused: isHovering }"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
> >
<span <span
v-for="(group, index) in duplicatedGroups" v-for="(chip, index) in duplicatedChips"
:key="index" :key="index"
class="review-chip" class="review-chip fragment-chip"
:class="group.hasReport ? 'has-report' : 'fragments-only'" @click="openRecallCard(chip)"
@click="goToReviewDetail(group)"
> >
<strong class="chip-task">{{ group.taskName }}</strong> <strong class="chip-task">{{ chip.taskName }}</strong>
<span class="chip-content">{{ group.content }}</span> <span class="chip-content">{{ chip.content }}</span>
</span> </span>
</div> </div>
</div> </div>
@@ -191,37 +235,54 @@ onMounted(() => {
<!-- 回忆卡片先回忆再对照 --> <!-- 回忆卡片先回忆再对照 -->
<el-dialog <el-dialog
v-model="recallCard.visible" v-model="recallCard.visible"
:title="recallCard.group?.taskName || '回忆一下'" :title="recallCard.fragment?.taskName || '回忆一下'"
width="560px" width="560px"
class="recall-card-dialog" class="recall-card-dialog"
> >
<template v-if="recallCard.group"> <template v-if="recallCard.fragment">
<p class="recall-question"> <p class="recall-question">
这次学习共有 {{ recallSessionItems.length }} 条记录看着下面这个片先试着回忆当时还学了什么 看着下面这个先试着回忆当时还学了什么
</p> </p>
<blockquote class="recall-snippet">{{ recallCard.group.content }}</blockquote> <blockquote class="recall-snippet">{{ recallCard.fragment.content }}</blockquote>
<p v-if="recallCard.loadError" class="recall-error">{{ recallCard.loadError }}</p>
<div v-if="recallCard.revealed" class="recall-full-list"> <div v-if="recallCard.revealed" class="recall-full-list">
<div v-if="recallCard.report" class="recall-full-item">
<span class="recall-item-tag tag-report">报告</span>
<span class="recall-item-content">{{ recallCard.report.content }}</span>
</div>
<div <div
v-for="item in recallSessionItems" v-for="item in recallCard.fragments"
:key="`${item.sourceType}-${item.id}`" :key="item.id"
class="recall-full-item" class="recall-full-item"
:class="{ 'is-clicked': item.id === recallCard.fragment?.id }"
> >
<span class="recall-item-tag" :class="item.sourceType === 'REPORT' ? 'tag-report' : 'tag-fragment'"> <span class="recall-item-tag tag-fragment">残片</span>
{{ item.sourceType === "REPORT" ? "报告" : "残片" }}
</span>
<span class="recall-item-content">{{ item.content }}</span> <span class="recall-item-content">{{ item.content }}</span>
</div> </div>
<el-empty
v-if="!recallCard.report && recallCard.fragments.length === 0"
description="暂未找到该会话的对照内容"
:image-size="48"
/>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<el-button v-if="!recallCard.revealed" type="success" plain @click="revealRecallContent"> <el-button
回忆好了展开对照 v-if="!recallCard.revealed"
type="success"
plain
:loading="recallCard.loadingSession"
@click="revealRecallContent"
>
展开对照
</el-button> </el-button>
<el-button v-else type="success" plain @click="openRecallDetail">进入详情页</el-button> <el-button v-if="!recallCard.revealed" @click="recallCard.visible = false">关闭</el-button>
<template v-else>
<el-button type="success" @click="goToRecallFromCard">前往回忆复习</el-button> <el-button type="success" @click="goToRecallFromCard">前往回忆复习</el-button>
<el-button @click="recallCard.visible = false">关闭</el-button> <el-button @click="recallCard.visible = false">关闭</el-button>
</template> </template>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
@@ -297,10 +358,6 @@ onMounted(() => {
margin-right: 3px; margin-right: 3px;
} }
.dot-report {
background: var(--green-600);
}
.dot-fragment { .dot-fragment {
background: #e6a23c; background: #e6a23c;
} }
@@ -315,20 +372,6 @@ onMounted(() => {
display: inline-flex; display: inline-flex;
gap: 14px; gap: 14px;
white-space: nowrap; white-space: nowrap;
animation: review-ticker 150s linear infinite;
}
.review-scroll-content.paused {
animation-play-state: paused;
}
@keyframes review-ticker {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
} }
.review-chip { .review-chip {
@@ -338,7 +381,7 @@ onMounted(() => {
padding: 8px 16px; padding: 8px 16px;
background: var(--surface-strong); background: var(--surface-strong);
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-left: 3px solid transparent; border-left: 3px solid #e6a23c;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: background 0.2s, box-shadow 0.2s; transition: background 0.2s, box-shadow 0.2s;
@@ -346,23 +389,8 @@ onMounted(() => {
user-select: none; user-select: none;
} }
.review-chip.has-report {
border-left-color: var(--green-600);
}
.review-chip.fragments-only {
border-left-color: #e6a23c;
}
.review-chip:hover { .review-chip:hover {
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
}
.review-chip.has-report:hover {
background: #e8f5e9;
}
.review-chip.fragments-only:hover {
background: #fff8e1; background: #fff8e1;
} }
@@ -401,6 +429,12 @@ onMounted(() => {
white-space: normal; white-space: normal;
} }
.recall-error {
margin: 0 0 10px;
color: var(--el-color-danger, #f56c6c);
font-size: 13px;
}
.recall-full-list { .recall-full-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -419,6 +453,11 @@ onMounted(() => {
border-radius: 6px; border-radius: 6px;
} }
.recall-full-item.is-clicked {
background: #fff8e1;
border-color: #e6a23c;
}
.recall-item-tag { .recall-item-tag {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 11px;