Files
lpt-fe/src/components/Welcome.vue
T

460 lines
11 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, "smart");
reviewItems.value = res?.data || [];
} catch {
// feed 加载失败不影响页面主体功能
}
};
// 回忆卡片:点击滚动内容先尝试回忆,再看原文
const recallCard = ref<{ visible: boolean; group: SessionGroup | null; revealed: boolean }>({
visible: false,
group: null,
revealed: false,
});
// 当前回忆卡片对应会话的全部记录
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 revealRecallContent = () => {
recallCard.value.revealed = true;
};
const openRecallDetail = () => {
const group = recallCard.value.group;
if (!group) return;
recallCard.value.visible = false;
router.push(`/review/detail/${group.navigateType}/${group.navigateId}`);
};
/** 从回忆卡片跳转到回忆复习页(先匹配标准导图节点) */
const goToRecallFromCard = async () => {
const group = recallCard.value.group;
if (!group) return;
recallCard.value.visible = false;
const tn = group.taskNum;
const content = group.content || "";
if (tn && content) {
try {
const { findNode } = await import("@/api/standardMindMap");
const res = await findNode(tn, content.substring(0, 500));
const data = res?.data;
if (data?.path) {
router.push(`/review/recall/${tn}?focusPath=${encodeURIComponent(data.path)}`);
return;
}
} catch { /* fall through */ }
router.push(`/review/recall/${tn}`);
} else {
router.push("/review");
}
};
const startStudy = () => {
router.push("/study");
};
const startReview = () => {
router.push("/review");
};
onMounted(() => {
loadReviewFeed();
});
</script>
<template>
<div>
<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>
</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>
</section>
<!-- 回忆卡片先回忆再对照 -->
<el-dialog
v-model="recallCard.visible"
:title="recallCard.group?.taskName || '回忆一下'"
width="560px"
>
<template v-if="recallCard.group">
<p class="recall-question">
这次学习共有 {{ recallSessionItems.length }} 条记录看着下面这个片段先试着回忆当时还学了什么
</p>
<blockquote class="recall-snippet">{{ recallCard.group.content }}</blockquote>
<div v-if="recallCard.revealed" class="recall-full-list">
<div
v-for="item in recallSessionItems"
:key="`${item.sourceType}-${item.id}`"
class="recall-full-item"
>
<span class="recall-item-tag" :class="item.sourceType === 'REPORT' ? 'tag-report' : 'tag-fragment'">
{{ item.sourceType === "REPORT" ? "报告" : "残片" }}
</span>
<span class="recall-item-content">{{ item.content }}</span>
</div>
</div>
</template>
<template #footer>
<el-button v-if="!recallCard.revealed" type="success" plain @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>
</template>
</el-dialog>
</div>
</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;
}
/* 回忆卡片 */
.recall-question {
margin: 0 0 12px;
font-size: 14px;
color: var(--text-primary);
line-height: 1.6;
}
.recall-snippet {
margin: 0 0 14px;
padding: 12px 16px;
border-left: 3px solid var(--green-600);
background: #f1f8e9;
border-radius: 0 6px 6px 0;
font-size: 14px;
color: var(--text-primary);
line-height: 1.7;
word-break: break-word;
white-space: normal;
}
.recall-full-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.recall-full-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: #fbfefc;
border: 1px solid var(--border-soft);
border-radius: 6px;
}
.recall-item-tag {
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
}
.tag-report {
background: #e8f5e9;
color: var(--green-700);
}
.tag-fragment {
background: #fff8e1;
color: #b8860b;
}
.recall-item-content {
flex: 1;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
white-space: normal;
}
@media (max-width: 900px) {
.welcome-banner {
flex-direction: column;
}
.quick-grid {
grid-template-columns: 1fr;
}
}
</style>