feat: 首页滚动条展示学习残片并支持滚轮手动滚动
This commit is contained in:
+168
-129
@@ -1,63 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import router from "@/router";
|
||||
import { getReviewFeed, type ReviewFeedItem } from "@/api/review";
|
||||
import { getReviewFeed, getTaskReview, type ReviewFeedItem } from "@/api/review";
|
||||
|
||||
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;
|
||||
taskName: string;
|
||||
taskNum: string;
|
||||
content: string;
|
||||
hasReport: boolean;
|
||||
navigateType: string;
|
||||
navigateId: number;
|
||||
}
|
||||
|
||||
// 按 sessionNum 分组,一个会话展示为一个 chip
|
||||
const sessionGroups = computed<SessionGroup[]>(() => {
|
||||
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 truncateContent = (content: string, maxLength = 120) =>
|
||||
content.length > maxLength ? content.substring(0, maxLength) + "..." : content;
|
||||
|
||||
const groups: SessionGroup[] = [];
|
||||
for (const [sessionNum, items] of map) {
|
||||
const first = items[0];
|
||||
const report = items.find(i => i.sourceType === "REPORT");
|
||||
const fragments = items.filter(i => i.sourceType === "FRAGMENT");
|
||||
|
||||
const rawContent = report?.content || fragments.map(f => f.content).join(";");
|
||||
const content = rawContent.length > 120
|
||||
? rawContent.substring(0, 120) + "..."
|
||||
: rawContent;
|
||||
|
||||
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;
|
||||
});
|
||||
// 滚动条只展示学习残片,每条残片单独作为 chip
|
||||
const fragmentChips = computed<FragmentChip[]>(() =>
|
||||
reviewItems.value
|
||||
.filter(item => item.sourceType === "FRAGMENT")
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
sessionNum: item.sessionNum,
|
||||
taskName: item.taskName,
|
||||
taskNum: item.taskNum,
|
||||
content: truncateContent(item.content),
|
||||
})),
|
||||
);
|
||||
|
||||
// 复制一份实现无缝循环
|
||||
const duplicatedGroups = computed(() => [...sessionGroups.value, ...sessionGroups.value]);
|
||||
const duplicatedChips = computed(() => [...fragmentChips.value, ...fragmentChips.value]);
|
||||
|
||||
const loadReviewFeed = async () => {
|
||||
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,
|
||||
group: null,
|
||||
fragment: null,
|
||||
report: null,
|
||||
fragments: [],
|
||||
revealed: false,
|
||||
loadingSession: false,
|
||||
loadError: "",
|
||||
});
|
||||
|
||||
// 当前回忆卡片对应会话的全部记录
|
||||
const recallSessionItems = computed(() => {
|
||||
const group = recallCard.value.group;
|
||||
if (!group) return [];
|
||||
return reviewItems.value.filter((i) => i.sessionNum === group.sessionNum);
|
||||
});
|
||||
|
||||
const goToReviewDetail = (group: SessionGroup) => {
|
||||
recallCard.value = { visible: true, group, revealed: false };
|
||||
const openRecallCard = (fragment: FragmentChip) => {
|
||||
recallCard.value = {
|
||||
visible: true,
|
||||
fragment,
|
||||
report: null,
|
||||
fragments: [],
|
||||
revealed: false,
|
||||
loadingSession: false,
|
||||
loadError: "",
|
||||
};
|
||||
};
|
||||
|
||||
const revealRecallContent = () => {
|
||||
recallCard.value.revealed = true;
|
||||
};
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
||||
const openRecallDetail = () => {
|
||||
const group = recallCard.value.group;
|
||||
if (!group) return;
|
||||
recallCard.value.visible = false;
|
||||
router.push(`/review/detail/${group.navigateType}/${group.navigateId}`);
|
||||
recallCard.value.loadingSession = true;
|
||||
recallCard.value.loadError = "";
|
||||
try {
|
||||
const res = await getTaskReview(fragment.taskNum);
|
||||
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 group = recallCard.value.group;
|
||||
if (!group) return;
|
||||
const fragment = recallCard.value.fragment;
|
||||
if (!fragment) return;
|
||||
recallCard.value.visible = false;
|
||||
const tn = group.taskNum;
|
||||
const content = group.content || "";
|
||||
const tn = fragment.taskNum;
|
||||
const content = fragment.content || "";
|
||||
if (tn && content) {
|
||||
try {
|
||||
const { findNode } = await import("@/api/standardMindMap");
|
||||
@@ -128,8 +129,50 @@ const startReview = () => {
|
||||
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(() => {
|
||||
loadReviewFeed();
|
||||
autoScroll = contentRef.value?.animate(
|
||||
[
|
||||
{ transform: "translateX(0)" },
|
||||
{ transform: "translateX(-50%)" },
|
||||
],
|
||||
{ duration: AUTO_SCROLL_DURATION, iterations: Infinity, easing: "linear" },
|
||||
) || null;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
autoScroll?.cancel();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -139,26 +182,27 @@ onMounted(() => {
|
||||
<div class="review-scroll-section surface-card">
|
||||
<div class="review-scroll-header">
|
||||
<span class="color-legend">
|
||||
<span class="dot dot-report"></span> 学习报告
|
||||
<span class="dot dot-fragment"></span> 学习残片
|
||||
</span>
|
||||
</div>
|
||||
<div class="review-scroll-track">
|
||||
<div
|
||||
class="review-scroll-track"
|
||||
@mouseenter="pauseAutoScroll"
|
||||
@mouseleave="resumeAutoScroll"
|
||||
@wheel.prevent="handleTrackWheel"
|
||||
>
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="review-scroll-content"
|
||||
:class="{ paused: isHovering }"
|
||||
@mouseenter="isHovering = true"
|
||||
@mouseleave="isHovering = false"
|
||||
>
|
||||
<span
|
||||
v-for="(group, index) in duplicatedGroups"
|
||||
v-for="(chip, index) in duplicatedChips"
|
||||
:key="index"
|
||||
class="review-chip"
|
||||
:class="group.hasReport ? 'has-report' : 'fragments-only'"
|
||||
@click="goToReviewDetail(group)"
|
||||
class="review-chip fragment-chip"
|
||||
@click="openRecallCard(chip)"
|
||||
>
|
||||
<strong class="chip-task">{{ group.taskName }}</strong>
|
||||
<span class="chip-content">{{ group.content }}</span>
|
||||
<strong class="chip-task">{{ chip.taskName }}</strong>
|
||||
<span class="chip-content">{{ chip.content }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,36 +235,53 @@ onMounted(() => {
|
||||
<!-- 回忆卡片:先回忆,再对照 -->
|
||||
<el-dialog
|
||||
v-model="recallCard.visible"
|
||||
:title="recallCard.group?.taskName || '回忆一下'"
|
||||
:title="recallCard.fragment?.taskName || '回忆一下'"
|
||||
width="560px"
|
||||
class="recall-card-dialog"
|
||||
>
|
||||
<template v-if="recallCard.group">
|
||||
<template v-if="recallCard.fragment">
|
||||
<p class="recall-question">
|
||||
这次学习共有 {{ recallSessionItems.length }} 条记录。看着下面这个片段,先试着回忆:当时还学了什么?
|
||||
看着下面这个残片,先试着回忆:当时还学了什么?
|
||||
</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.report" class="recall-full-item">
|
||||
<span class="recall-item-tag tag-report">报告</span>
|
||||
<span class="recall-item-content">{{ recallCard.report.content }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in recallSessionItems"
|
||||
:key="`${item.sourceType}-${item.id}`"
|
||||
v-for="item in recallCard.fragments"
|
||||
:key="item.id"
|
||||
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'">
|
||||
{{ item.sourceType === "REPORT" ? "报告" : "残片" }}
|
||||
</span>
|
||||
<span class="recall-item-tag tag-fragment">残片</span>
|
||||
<span class="recall-item-content">{{ item.content }}</span>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!recallCard.report && recallCard.fragments.length === 0"
|
||||
description="暂未找到该会话的对照内容"
|
||||
:image-size="48"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<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 v-else type="success" plain @click="openRecallDetail">进入详情页</el-button>
|
||||
<el-button type="success" @click="goToRecallFromCard">前往回忆复习</el-button>
|
||||
<el-button @click="recallCard.visible = false">关闭</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 @click="recallCard.visible = false">关闭</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -297,10 +358,6 @@ onMounted(() => {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.dot-report {
|
||||
background: var(--green-600);
|
||||
}
|
||||
|
||||
.dot-fragment {
|
||||
background: #e6a23c;
|
||||
}
|
||||
@@ -315,20 +372,6 @@ onMounted(() => {
|
||||
display: inline-flex;
|
||||
gap: 14px;
|
||||
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 {
|
||||
@@ -338,7 +381,7 @@ onMounted(() => {
|
||||
padding: 8px 16px;
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-left: 3px solid transparent;
|
||||
border-left: 3px solid #e6a23c;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
@@ -346,23 +389,8 @@ onMounted(() => {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.review-chip.has-report {
|
||||
border-left-color: var(--green-600);
|
||||
}
|
||||
|
||||
.review-chip.fragments-only {
|
||||
border-left-color: #e6a23c;
|
||||
}
|
||||
|
||||
.review-chip:hover {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.review-chip.has-report:hover {
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
.review-chip.fragments-only:hover {
|
||||
background: #fff8e1;
|
||||
}
|
||||
|
||||
@@ -401,6 +429,12 @@ onMounted(() => {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.recall-error {
|
||||
margin: 0 0 10px;
|
||||
color: var(--el-color-danger, #f56c6c);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.recall-full-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -419,6 +453,11 @@ onMounted(() => {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.recall-full-item.is-clicked {
|
||||
background: #fff8e1;
|
||||
border-color: #e6a23c;
|
||||
}
|
||||
|
||||
.recall-item-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
|
||||
Reference in New Issue
Block a user