Files
lpt-fe/src/components/ReviewDetail.vue
T
cat-win e65b800e01 chore: 移除上传导图 + 优化复习引导提示
- 删除 ReviewMindMap 接口及 upload/get/upsert API
- 删除 ReviewDetail 上传 UI 和思维导图预览
- 回忆复习按钮移至内容元信息区
- 引导提示改为渐进式:折叠时引导展开,展开后引导点击节点
- 提示语用告知语气,无表情符号

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:56:03 +08:00

491 lines
12 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, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getFragmentDetail,
getReportDetail,
getTaskReview,
type ReviewFeedItem,
} from "@/api/review";
import { getSessionDetail } from "@/api/studySessions";
import { updateFragments } from "@/api/reportFragments";
import { findNode } from "@/api/standardMindMap";
import { ElMessage } from "element-plus";
import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
const route = useRoute();
const router = useRouter();
/** 调用 findNode API 匹配标准导图中的最近节点,跳转到回忆页 */
const goToRecall = async () => {
if (!content.value || !content.value.trim()) {
router.push(`/review/recall/${taskNum.value}`);
return;
}
try {
const res = await findNode(taskNum.value, content.value.substring(0, 500));
const data = res?.data;
if (data?.path) {
router.push(`/review/recall/${taskNum.value}?focusPath=${encodeURIComponent(data.path)}`);
} else {
router.push(`/review/recall/${taskNum.value}`);
}
} catch {
router.push(`/review/recall/${taskNum.value}`);
}
};
const contentType = computed(() => route.params.type as string);
const contentId = computed(() => Number(route.params.id));
const loading = ref(true);
const content = ref("");
const sourceTypeLabel = ref("");
const taskNum = ref("");
const isEditing = ref(false);
const editContent = ref("");
const saving = ref(false);
const sessionInfo = ref<{
taskName: string;
sessionNum: string;
actualTime?: number;
effectiveTime?: number;
effectivenessRatio?: number;
startTime?: string;
} | null>(null);
const taskSessions = ref<{
sessionNum: string;
startTime: string;
report: ReviewFeedItem | null;
fragments: ReviewFeedItem[];
}[]>([]);
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 formatRatio = (ratio?: number): string => {
if (ratio == null) return "-";
return `${(ratio * 100).toFixed(1)}%`;
};
const loadTaskHistory = async (tn: string) => {
try {
const res = await getTaskReview(tn);
const items: ReviewFeedItem[] = res?.data || [];
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 loadDetail = async () => {
loading.value = true;
isEditing.value = false;
editContent.value = "";
content.value = "";
sourceTypeLabel.value = "";
taskNum.value = "";
sessionInfo.value = null;
taskSessions.value = [];
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 || "";
const sessionNum = data?.sessionNum;
if (sessionNum) {
try {
const sessionRes = await getSessionDetail(sessionNum);
const info = sessionRes?.data;
sessionInfo.value = info || null;
taskNum.value = info?.taskNum || "";
if (info?.taskNum) {
await loadTaskHistory(info.taskNum);
}
} catch {
// 非关键数据
}
}
} finally {
loading.value = false;
}
};
const goToDetail = (type: string, id: number) => {
router.push(`/review/detail/${type}/${id}`);
};
const enterEdit = () => {
editContent.value = content.value;
isEditing.value = true;
};
const cancelEdit = () => {
isEditing.value = false;
editContent.value = "";
};
const saveEdit = async () => {
if (!editContent.value.trim()) {
ElMessage.warning("内容不能为空");
return;
}
if (editContent.value === content.value) {
isEditing.value = false;
return;
}
saving.value = true;
try {
const res = await updateFragments(contentId.value, editContent.value);
if (res?.code === 200) {
content.value = editContent.value;
isEditing.value = false;
if (taskNum.value) {
await loadTaskHistory(taskNum.value);
}
ElMessage.success("保存成功");
} else {
ElMessage.error(res?.message || "保存失败");
}
} catch (e: any) {
ElMessage.error(e.message || "请求失败");
} finally {
saving.value = false;
}
};
watch(
() => [contentType.value, contentId.value],
() => {
loadDetail();
},
{ immediate: true },
);
</script>
<template>
<section class="review-detail-page">
<!-- 当前条目内容 -->
<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>
<el-button
v-if="contentType === 'fragment' && !isEditing"
type="primary"
size="small"
@click="enterEdit"
>
编辑
</el-button>
<el-button
v-if="taskNum"
type="success"
size="small"
@click="goToRecall"
>
回忆复习
</el-button>
</div>
<div v-if="sessionInfo" class="session-stats">
<span>实际{{ formatDuration(sessionInfo.actualTime) }}</span>
<span>有效{{ formatDuration(sessionInfo.effectiveTime) }}</span>
<span>效率{{ formatRatio(sessionInfo.effectivenessRatio) }}</span>
</div>
<!-- 编辑模式 -->
<div v-if="isEditing" class="edit-area">
<el-input
v-model="editContent"
type="textarea"
:rows="6"
placeholder="请输入学习内容"
/>
<div class="edit-actions">
<el-button @click="cancelEdit">取消</el-button>
<el-button type="success" :loading="saving" @click="saveEdit">保存</el-button>
</div>
</div>
<!-- 只读模式 -->
<template v-else>
<div class="content-body" v-if="content">
<MarkdownRenderer :content="content" />
</div>
<div v-else class="empty-content">暂无内容</div>
</template>
</article>
<!-- 该任务下的所有学习记录 -->
<article class="surface-card history-card" v-if="taskSessions.length > 0">
<h3>该任务下的所有学习记录</h3>
<div
v-for="session in taskSessions"
:key="session.sessionNum"
class="history-session"
:class="{ 'is-current': session.sessionNum === sessionInfo?.sessionNum }"
>
<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 class="session-report" v-if="session.report">
<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 class="session-fragments" v-if="session.fragments.length > 0">
<span
v-for="(f, idx) in session.fragments"
:key="f.id"
class="clickable-text"
:class="{ active: contentType === 'fragment' && f.id === contentId }"
@click="goToDetail('fragment', f.id)"
>
<span class="label-tag fragment-tag">残片{{ idx + 1 }}</span>
{{ f.content.length > 150 ? f.content.substring(0, 150) + "..." : f.content }}
</span>
</div>
</div>
</article>
</section>
</template>
<style scoped>
.review-detail-page {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 900px;
margin: 0 auto;
}
.content-card {
padding: 22px;
min-height: 120px;
}
.content-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.session-stats {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin: -6px 0 16px;
color: var(--text-secondary);
font-size: 13px;
}
.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;
word-break: break-word;
overflow-wrap: break-word;
}
.content-body p {
margin: 0;
min-height: 1em;
}
.empty-content {
color: var(--text-secondary);
text-align: center;
padding: 40px 0;
}
/* 历史记录卡片 */
.history-card {
padding: 20px 22px;
}
.history-card h3 {
margin: 0 0 16px;
color: var(--green-900);
font-size: 16px;
}
.history-session {
padding: 14px 0;
border-bottom: 1px solid var(--border-soft);
}
.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;
flex-direction: column;
gap: 4px;
}
.clickable-text {
display: block;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
word-break: break-word;
overflow-wrap: break-word;
}
.clickable-text:hover {
background: var(--surface-strong);
}
.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;
}
.edit-area {
margin-top: 12px;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
@media (max-width: 900px) {
.form-row {
flex-direction: column;
align-items: stretch;
}
}
</style>