e2f7276b92
去掉 v-if 和 visibility 条件控制,滚动区域结构在页面加载时即存在于 DOM 中, 芯片通过 v-for 响应式插入,避免数据到达时整块 DOM 节点创建/销毁导致的跳变
338 lines
7.5 KiB
Vue
338 lines
7.5 KiB
Vue
<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>
|