e65b800e01
- 删除 ReviewMindMap 接口及 upload/get/upsert API - 删除 ReviewDetail 上传 UI 和思维导图预览 - 回忆复习按钮移至内容元信息区 - 引导提示改为渐进式:折叠时引导展开,展开后引导点击节点 - 提示语用告知语气,无表情符号 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
491 lines
12 KiB
Vue
491 lines
12 KiB
Vue
<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>
|