Files
lpt-fe/src/components/Welcome.vue
T
cat-shark e2f7276b92 fix: 首页滚动条始终渲染 DOM,消除加载生硬感
去掉 v-if 和 visibility 条件控制,滚动区域结构在页面加载时即存在于 DOM 中,
芯片通过 v-for 响应式插入,避免数据到达时整块 DOM 节点创建/销毁导致的跳变
2026-05-23 23:08:35 +08:00

338 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import router from "@/router";
import { getReviewFeed, type ReviewFeedItem } from "@/api/review";
const reviewItems = ref<ReviewFeedItem[]>([]);
const isHovering = ref(false);
interface SessionGroup {
sessionNum: string;
taskName: string;
taskNum: string;
content: string;
hasReport: boolean;
fragmentCount: number;
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 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,
fragmentCount: fragments.length,
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 loadReviewFeed = async () => {
try {
const res = await getReviewFeed(100);
reviewItems.value = res?.data || [];
} catch {
// feed 加载失败不影响页面主体功能
}
};
const goToReviewDetail = (group: SessionGroup) => {
router.push(`/review/detail/${group.navigateType}/${group.navigateId}`);
};
const startStudy = () => {
router.push("/study");
};
const startReview = () => {
router.push("/review");
};
onMounted(() => {
loadReviewFeed();
});
</script>
<template>
<section class="welcome-page">
<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-content"
:class="{ paused: isHovering }"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
>
<span
v-for="(group, index) in duplicatedGroups"
:key="index"
class="review-chip"
:class="group.hasReport ? 'has-report' : 'fragments-only'"
@click="goToReviewDetail(group)"
>
<strong class="chip-task">{{ group.taskName }}</strong>
<span class="chip-content">{{ group.content }}</span>
</span>
</div>
</div>
</div>
<div class="welcome-banner surface-card">
<div>
<h1 class="page-title">欢迎回来准备好继续学习了吗</h1>
<p class="page-subtitle">
先挑选任务开始一次会话再在复习页回顾你的学习轨迹
</p>
</div>
<el-tag type="success" effect="dark" size="large">今日模式稳步推进</el-tag>
</div>
<div class="quick-grid">
<article class="quick-card surface-card">
<h3>学习会话</h3>
<p>进入任务列表选择当前最重要的一项并开始学习计时</p>
<el-button type="success" size="large" @click="startStudy">开始学习</el-button>
</article>
<article class="quick-card surface-card">
<h3>复习回看</h3>
<p>查看学习报告与残片数量明确哪些内容需要优先复习</p>
<el-button plain type="success" size="large" @click="startReview">开始复习</el-button>
</article>
</div>
<div class="tips-board surface-card">
<h3>建议流程</h3>
<ol>
<li>进入学习页确认任务材料地址与优先级</li>
<li>开始会话并在休息时记录学习残片</li>
<li>结束会话后填写总结再回到复习页检视数据</li>
</ol>
</div>
</section>
</template>
<style scoped>
.welcome-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.welcome-banner {
padding: 20px 22px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.quick-card {
padding: 22px;
display: flex;
flex-direction: column;
gap: 10px;
}
.quick-card h3 {
margin: 0;
color: var(--green-900);
}
.quick-card p {
margin: 0;
color: var(--text-secondary);
}
.quick-card .el-button {
margin-top: auto;
width: 100%;
}
/* 复习滚动条区域 */
.review-scroll-section {
padding: 14px 22px;
overflow: hidden;
}
.review-scroll-header {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.color-legend {
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 12px;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 3px;
}
.dot-report {
background: var(--green-600);
}
.dot-fragment {
background: #e6a23c;
}
.review-scroll-track {
overflow: hidden;
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);
}
.review-scroll-content {
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 {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: var(--surface-strong);
border: 1px solid var(--border-soft);
border-left: 3px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
flex-shrink: 0;
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;
}
.chip-task {
color: var(--green-700);
font-size: 13px;
flex-shrink: 0;
}
.chip-content {
color: var(--text-secondary);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
}
.tips-board {
padding: 20px 22px;
}
.tips-board h3 {
margin: 0;
color: var(--green-900);
}
.tips-board ol {
margin: 10px 0 0;
padding-left: 20px;
color: var(--text-secondary);
}
.tips-board li + li {
margin-top: 8px;
}
@media (max-width: 900px) {
.welcome-banner {
flex-direction: column;
}
.quick-grid {
grid-template-columns: 1fr;
}
}
</style>