feat(review): 实现复习内容滚动展示与交互

- Welcome.vue 新增复习内容水平滚动条,展示报告/残片摘要
- 新增 ReviewDetail.vue 复习详情页,展示完整内容及会话上下文
- 新增 review.ts API 模块,路由注册 /review/detail/:type/:id
This commit is contained in:
2026-05-23 19:22:55 +08:00
parent 3a3a96da3c
commit 52045ecbeb
5 changed files with 416 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import request from "@/utils/request";
export interface ReviewFeedItem {
id: number;
sessionNum: string;
taskNum: string;
taskName: string;
sourceType: "REPORT" | "FRAGMENT";
content: string;
createdTime: string;
}
export const getReviewFeed = (limit: number = 30) => {
return request.get("/review/feed", { limit });
};
export const getReportDetail = (id: number) => {
return request.get(`/review/report/${id}`);
};
export const getFragmentDetail = (id: number) => {
return request.get(`/review/fragment/${id}`);
};
+4
View File
@@ -17,3 +17,7 @@ export const endSession = (sessionNum: string, content: string = "任务结束")
export const startOrContinueStudySession = (taskNum: string) => {
return request.get(`/tasks/${taskNum}/study-sessions/start-or-continue`);
};
export const getSessionDetail = (sessionNum: string) => {
return request.get(`/study-sessions/${sessionNum}`);
};
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getReportDetail, getFragmentDetail } from "@/api/review";
import { getSessionDetail } from "@/api/studySessions";
const route = useRoute();
const router = useRouter();
const contentType = computed(() => route.params.type as string); // 'report' | 'fragment'
const contentId = computed(() => Number(route.params.id));
const loading = ref(true);
const content = ref("");
const sourceTypeLabel = ref("");
// 会话相关信息(用于回忆上下文)
const sessionInfo = ref<{
taskName: string;
sessionNum: string;
actualTime?: number;
effectiveTime?: number;
effectivenessRatio?: number;
startTime?: string;
} | null>(null);
const formatDuration = (seconds: number): string => {
if (seconds == null) return "-";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}小时${m}分钟`;
if (m > 0) return `${m}分钟${s}`;
return `${s}`;
};
const loadDetail = async () => {
loading.value = true;
try {
let res;
if (contentType.value === "report") {
res = await getReportDetail(contentId.value);
sourceTypeLabel.value = "学习报告";
} else {
res = await getFragmentDetail(contentId.value);
sourceTypeLabel.value = "学习残片";
}
const data = res?.data;
content.value = data?.content || "";
// 通过 sessionNum 获取会话上下文信息
const sessionNum = data?.sessionNum;
if (sessionNum) {
try {
const sessionRes = await getSessionDetail(sessionNum);
sessionInfo.value = sessionRes?.data || null;
} catch {
// 会话信息非关键,加载失败不影响内容展示
}
}
} finally {
loading.value = false;
}
};
onMounted(() => {
loadDetail();
});
</script>
<template>
<section class="review-detail-page">
<header class="surface-card detail-head">
<el-button @click="router.back()" :disabled="loading"> 返回</el-button>
<div>
<h1 class="page-title">复习详情</h1>
<p class="page-subtitle">查看完整内容回忆当时所学</p>
</div>
</header>
<article class="surface-card content-card" v-loading="loading">
<div class="content-meta">
<el-tag
:type="contentType === 'report' ? 'success' : 'warning'"
effect="dark"
size="small"
>
{{ sourceTypeLabel }}
</el-tag>
<span v-if="sessionInfo" class="meta-task-name">
任务{{ sessionInfo.taskName }}
</span>
<span v-if="sessionInfo?.startTime" class="meta-time">
学习时间{{ sessionInfo.startTime?.replace("T", " ")?.substring(0, 16) }}
</span>
</div>
<div class="content-body" v-if="content">
<p v-for="(line, idx) in content.split('\n')" :key="idx">
{{ line || " " }}
</p>
</div>
<div v-else class="empty-content">暂无内容</div>
</article>
<article
class="surface-card session-card"
v-if="sessionInfo"
>
<h3>会话上下文</h3>
<div class="session-grid">
<div class="session-item">
<span class="label">任务名称</span>
<strong>{{ sessionInfo.taskName }}</strong>
</div>
<div class="session-item">
<span class="label">会话编号</span>
<strong>{{ sessionInfo.sessionNum }}</strong>
</div>
<div class="session-item" v-if="sessionInfo.actualTime != null">
<span class="label">实际学习时间</span>
<strong>{{ formatDuration(sessionInfo.actualTime) }}</strong>
</div>
<div class="session-item" v-if="sessionInfo.effectiveTime != null">
<span class="label">有效学习时间</span>
<strong>{{ formatDuration(sessionInfo.effectiveTime) }}</strong>
</div>
<div class="session-item" v-if="sessionInfo.effectivenessRatio != null">
<span class="label">效率比</span>
<strong>{{ (sessionInfo.effectivenessRatio * 100).toFixed(1) }}%</strong>
</div>
</div>
</article>
</section>
</template>
<style scoped>
.review-detail-page {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 900px;
}
.detail-head {
padding: 20px 22px;
display: flex;
align-items: flex-start;
gap: 16px;
}
.detail-head .el-button {
flex-shrink: 0;
margin-top: 2px;
}
.detail-head h1 {
margin: 0;
}
.detail-head p {
margin: 2px 0 0;
}
.content-card {
padding: 22px;
min-height: 120px;
}
.content-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.meta-task-name {
font-weight: 600;
color: var(--green-800);
font-size: 14px;
}
.meta-time {
color: var(--text-secondary);
font-size: 13px;
}
.content-body {
line-height: 1.8;
color: var(--text-primary);
font-size: 15px;
}
.content-body p {
margin: 0;
min-height: 1em;
}
.empty-content {
color: var(--text-secondary);
text-align: center;
padding: 40px 0;
}
/* 会话上下文卡片 */
.session-card {
padding: 20px 22px;
}
.session-card h3 {
margin: 0 0 14px;
color: var(--green-900);
font-size: 16px;
}
.session-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.session-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.session-item .label {
color: var(--text-secondary);
font-size: 12px;
}
.session-item strong {
color: var(--text-primary);
font-size: 15px;
}
@media (max-width: 768px) {
.detail-head {
flex-direction: column;
}
.session-grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
+136
View File
@@ -1,5 +1,37 @@
<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);
const truncatedItems = computed(() =>
reviewItems.value.map((item) => ({
...item,
display:
item.content.length > 50
? item.content.substring(0, 50) + "..."
: item.content,
}))
);
// 复制一份实现无缝循环
const duplicatedItems = computed(() => [...truncatedItems.value, ...truncatedItems.value]);
const loadReviewFeed = async () => {
try {
const res = await getReviewFeed(30);
reviewItems.value = res?.data || [];
} catch {
// feed 加载失败不影响页面主体功能
}
};
const goToReviewDetail = (item: ReviewFeedItem) => {
const type = item.sourceType === "REPORT" ? "report" : "fragment";
router.push(`/review/detail/${type}/${item.id}`);
};
const startStudy = () => {
router.push("/study");
@@ -8,6 +40,10 @@ const startStudy = () => {
const startReview = () => {
router.push("/review");
};
onMounted(() => {
loadReviewFeed();
});
</script>
<template>
@@ -36,6 +72,36 @@ const startReview = () => {
</article>
</div>
<div class="review-scroll-section surface-card" v-if="reviewItems.length > 0">
<h3>复习滚动条</h3>
<div class="review-scroll-track">
<div
class="review-scroll-content"
:class="{ paused: isHovering }"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
>
<span
v-for="(item, index) in duplicatedItems"
:key="index"
class="review-chip"
@click="goToReviewDetail(item)"
>
<el-tag
size="small"
:type="item.sourceType === 'REPORT' ? 'success' : 'warning'"
effect="plain"
disable-transitions
>
{{ item.sourceType === 'REPORT' ? '报告' : '残片' }}
</el-tag>
<strong>{{ item.taskName }}</strong>
<span>{{ item.display }}</span>
</span>
</div>
</div>
</div>
<div class="tips-board surface-card">
<h3>建议流程</h3>
<ol>
@@ -90,6 +156,76 @@ const startReview = () => {
width: 100%;
}
/* 复习滚动条区域 */
.review-scroll-section {
padding: 20px 22px;
overflow: hidden;
}
.review-scroll-section h3 {
margin: 0 0 12px;
color: var(--green-900);
font-size: 16px;
}
.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 80s 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: 8px;
padding: 8px 14px;
background: var(--surface-strong);
border: 1px solid var(--border-soft);
border-radius: 24px;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
flex-shrink: 0;
user-select: none;
}
.review-chip:hover {
background: #e8f5e9;
box-shadow: var(--shadow-soft);
}
.review-chip strong {
color: var(--green-700);
font-size: 13px;
}
.review-chip span {
color: var(--text-secondary);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.tips-board {
padding: 20px 22px;
}
+5
View File
@@ -27,6 +27,11 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/components/Review.vue"),
meta: { requiresAuth: true },
},
{
path: "/review/detail/:type/:id",
component: () => import("@/components/ReviewDetail.vue"),
meta: { requiresAuth: true },
},
{
path: "/start-task/:taskNum",
name: "start-task",