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) => {
|
export const startOrContinueStudySession = (taskNum: string) => {
|
||||||
return request.get(`/tasks/${taskNum}/study-sessions/start-or-continue`);
|
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">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
import router from "@/router";
|
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 = () => {
|
const startStudy = () => {
|
||||||
router.push("/study");
|
router.push("/study");
|
||||||
@@ -8,6 +40,10 @@ const startStudy = () => {
|
|||||||
const startReview = () => {
|
const startReview = () => {
|
||||||
router.push("/review");
|
router.push("/review");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadReviewFeed();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,6 +72,36 @@ const startReview = () => {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</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">
|
<div class="tips-board surface-card">
|
||||||
<h3>建议流程</h3>
|
<h3>建议流程</h3>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -90,6 +156,76 @@ const startReview = () => {
|
|||||||
width: 100%;
|
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 {
|
.tips-board {
|
||||||
padding: 20px 22px;
|
padding: 20px 22px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/components/Review.vue"),
|
component: () => import("@/components/Review.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/review/detail/:type/:id",
|
||||||
|
component: () => import("@/components/ReviewDetail.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/start-task/:taskNum",
|
path: "/start-task/:taskNum",
|
||||||
name: "start-task",
|
name: "start-task",
|
||||||
|
|||||||
Reference in New Issue
Block a user