feat(review): 实现复习内容滚动展示与交互
- Welcome.vue 新增复习内容水平滚动条,展示报告/残片摘要 - 新增 ReviewDetail.vue 复习详情页,展示完整内容及会话上下文 - 新增 review.ts API 模块,路由注册 /review/detail/:type/:id
This commit is contained in:
@@ -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}`);
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user