feat: add task learning history to review detail page

This commit is contained in:
2026-05-23 21:00:59 +08:00
parent 70d347e06e
commit 749d9752fd
2 changed files with 178 additions and 49 deletions
+4
View File
@@ -21,3 +21,7 @@ export const getReportDetail = (id: number) => {
export const getFragmentDetail = (id: number) => { export const getFragmentDetail = (id: number) => {
return request.get(`/review/fragment/${id}`); return request.get(`/review/fragment/${id}`);
}; };
export const getTaskReview = (taskNum: string) => {
return request.get(`/review/task/${taskNum}`);
};
+174 -49
View File
@@ -1,20 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { getReportDetail, getFragmentDetail } from "@/api/review"; import { getReportDetail, getFragmentDetail, getTaskReview, type ReviewFeedItem } from "@/api/review";
import { getSessionDetail } from "@/api/studySessions"; import { getSessionDetail } from "@/api/studySessions";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const contentType = computed(() => route.params.type as string); // 'report' | 'fragment' const contentType = computed(() => route.params.type as string);
const contentId = computed(() => Number(route.params.id)); const contentId = computed(() => Number(route.params.id));
const loading = ref(true); const loading = ref(true);
const content = ref(""); const content = ref("");
const sourceTypeLabel = ref(""); const sourceTypeLabel = ref("");
const taskNum = ref("");
// 会话相关信息(用于回忆上下文) // 当前会话信息
const sessionInfo = ref<{ const sessionInfo = ref<{
taskName: string; taskName: string;
sessionNum: string; sessionNum: string;
@@ -24,6 +25,14 @@ const sessionInfo = ref<{
startTime?: string; startTime?: string;
} | null>(null); } | null>(null);
// 该任务下的所有学习记录(按 session 分组)
const taskSessions = ref<{
sessionNum: string;
startTime: string;
report: ReviewFeedItem | null;
fragments: ReviewFeedItem[];
}[]>([]);
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
if (seconds == null) return "-"; if (seconds == null) return "-";
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -48,14 +57,20 @@ const loadDetail = async () => {
const data = res?.data; const data = res?.data;
content.value = data?.content || ""; content.value = data?.content || "";
// 通过 sessionNum 获取会话上下文信息
const sessionNum = data?.sessionNum; const sessionNum = data?.sessionNum;
if (sessionNum) { if (sessionNum) {
try { try {
const sessionRes = await getSessionDetail(sessionNum); const sessionRes = await getSessionDetail(sessionNum);
sessionInfo.value = sessionRes?.data || null; const info = sessionRes?.data;
sessionInfo.value = info || null;
taskNum.value = info?.taskNum || "";
// 加载该任务下的所有报告和残片
if (info?.taskNum) {
await loadTaskHistory(info.taskNum, sessionNum);
}
} catch { } catch {
// 会话信息非关键,加载失败不影响内容展示 // 非关键数据
} }
} }
} finally { } finally {
@@ -63,6 +78,42 @@ const loadDetail = async () => {
} }
}; };
const loadTaskHistory = async (tn: string, currentSessionNum: string) => {
try {
const res = await getTaskReview(tn);
const items: ReviewFeedItem[] = res?.data || [];
// 按 sessionNum 分组
const map = new Map<string, { report: ReviewFeedItem | null; fragments: ReviewFeedItem[]; startTime: string }>();
for (const item of items) {
const entry = map.get(item.sessionNum) || { report: null, fragments: [], startTime: item.createdTime };
if (item.sourceType === "REPORT") {
entry.report = item;
} else {
entry.fragments.push(item);
}
if (!map.has(item.sessionNum)) {
map.set(item.sessionNum, entry);
}
}
taskSessions.value = Array.from(map.entries())
.map(([sessionNum, data]) => ({
sessionNum,
startTime: data.startTime,
report: data.report,
fragments: data.fragments,
}))
.sort((a, b) => b.startTime.localeCompare(a.startTime));
} catch {
// 非关键数据
}
};
const goToDetail = (type: string, id: number) => {
router.push(`/review/detail/${type}/${id}`);
};
onMounted(() => { onMounted(() => {
loadDetail(); loadDetail();
}); });
@@ -78,6 +129,7 @@ onMounted(() => {
</div> </div>
</header> </header>
<!-- 当前条目内容 -->
<article class="surface-card content-card" v-loading="loading"> <article class="surface-card content-card" v-loading="loading">
<div class="content-meta"> <div class="content-meta">
<el-tag <el-tag
@@ -103,31 +155,44 @@ onMounted(() => {
<div v-else class="empty-content">暂无内容</div> <div v-else class="empty-content">暂无内容</div>
</article> </article>
<article <!-- 该任务下的所有学习记录 -->
class="surface-card session-card" <article class="surface-card history-card" v-if="taskSessions.length > 0">
v-if="sessionInfo" <h3>该任务下的所有学习记录</h3>
> <div
<h3>会话上下文</h3> v-for="session in taskSessions"
<div class="session-grid"> :key="session.sessionNum"
<div class="session-item"> class="history-session"
<span class="label">任务名称</span> :class="{ 'is-current': session.sessionNum === sessionInfo?.sessionNum }"
<strong>{{ sessionInfo.taskName }}</strong> >
<div class="session-header">
<span class="session-date">
{{ session.startTime?.replace("T", " ")?.substring(0, 16) }}
</span>
<span class="session-badge" v-if="session.sessionNum === sessionInfo?.sessionNum">当前</span>
</div> </div>
<div class="session-item">
<span class="label">会话编号</span> <div class="session-report" v-if="session.report">
<strong>{{ sessionInfo.sessionNum }}</strong> <span
class="clickable-text"
:class="{ active: contentType === 'report' && session.report.id === contentId }"
@click="goToDetail('report', session.report!.id)"
>
<span class="label-tag report-tag">报告</span>
{{ session.report.content.length > 150 ? session.report.content.substring(0, 150) + "..." : session.report.content }}
</span>
</div> </div>
<div class="session-item" v-if="sessionInfo.actualTime != null">
<span class="label">实际学习时间</span> <div class="session-fragments" v-if="session.fragments.length > 0">
<strong>{{ formatDuration(sessionInfo.actualTime) }}</strong> <span
</div> v-for="f in session.fragments"
<div class="session-item" v-if="sessionInfo.effectiveTime != null"> :key="f.id"
<span class="label">有效学习时间</span> class="clickable-text"
<strong>{{ formatDuration(sessionInfo.effectiveTime) }}</strong> :class="{ active: contentType === 'fragment' && f.id === contentId }"
</div> @click="goToDetail('fragment', f.id)"
<div class="session-item" v-if="sessionInfo.effectivenessRatio != null"> >
<span class="label">效率比</span> <span class="label-tag fragment-tag">残片</span>
<strong>{{ (sessionInfo.effectivenessRatio * 100).toFixed(1) }}%</strong> {{ f.content.length > 150 ? f.content.substring(0, 150) + "..." : f.content }}
</span>
</div> </div>
</div> </div>
</article> </article>
@@ -203,46 +268,106 @@ onMounted(() => {
padding: 40px 0; padding: 40px 0;
} }
/* 会话上下文卡片 */ /* 历史记录卡片 */
.session-card { .history-card {
padding: 20px 22px; padding: 20px 22px;
} }
.session-card h3 { .history-card h3 {
margin: 0 0 14px; margin: 0 0 16px;
color: var(--green-900); color: var(--green-900);
font-size: 16px; font-size: 16px;
} }
.session-grid { .history-session {
display: grid; padding: 14px 0;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); border-bottom: 1px solid var(--border-soft);
gap: 14px;
} }
.session-item { .history-session:last-child {
border-bottom: none;
}
.history-session.is-current {
background: #f1f8e9;
margin: 0 -10px;
padding: 14px 10px;
border-radius: 8px;
}
.session-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.session-date {
font-size: 12px;
color: var(--text-secondary);
}
.session-badge {
font-size: 11px;
background: var(--green-600);
color: #fff;
padding: 1px 8px;
border-radius: 10px;
}
.session-report {
margin-bottom: 6px;
}
.session-fragments {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.session-item .label { .clickable-text {
color: var(--text-secondary); display: block;
font-size: 12px; font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
} }
.session-item strong { .clickable-text:hover {
color: var(--text-primary); background: var(--surface-strong);
font-size: 15px; }
.clickable-text.active {
background: #e8f5e9;
border-left: 3px solid var(--green-600);
}
.label-tag {
display: inline-block;
font-size: 11px;
padding: 0 6px;
border-radius: 3px;
margin-right: 6px;
font-weight: 600;
vertical-align: middle;
}
.report-tag {
background: #e8f5e9;
color: var(--green-700);
}
.fragment-tag {
background: #fff8e1;
color: #b8860b;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.detail-head { .detail-head {
flex-direction: column; flex-direction: column;
} }
.session-grid {
grid-template-columns: 1fr 1fr;
}
} }
</style> </style>