feat: 首页滚动条展示学习残片并支持滚轮手动滚动
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user