feat(复习):回忆复习页面与入口

- 新增 ReviewRecall.vue 回忆复习页面(双栏布局)
  - 左栏:回忆大纲输入区、对比结果展示(着色树)
  - 右栏:标准导图(默认折叠)、编辑、重新生成
  - 遗漏项列表,可点击回看原文
  - 回忆历史记录查询
- api/standardMindMap.ts:标准导图与回忆对比 API
- router:新增 /review/recall/:taskNum 路由
- Review.vue 行操作中增加回忆复习按钮
- ReviewDetail.vue 思维导图区增加回忆复习入口
This commit is contained in:
2026-07-03 08:54:16 +08:00
parent dc7469ec3f
commit b24483b03b
11 changed files with 1205 additions and 260 deletions
-30
View File
@@ -11,17 +11,13 @@ vi.mock('@/utils/request', () => ({
import request from '@/utils/request' import request from '@/utils/request'
import { import {
createReviewApplication,
deleteReviewApplication,
getFragmentDetail, getFragmentDetail,
getReportDetail, getReportDetail,
getReviewApplications,
getReviewFeed, getReviewFeed,
getReviewMindMap, getReviewMindMap,
getReviewTaskStats, getReviewTaskStats,
getReviewTaskStatsByTask, getReviewTaskStatsByTask,
getTaskReview, getTaskReview,
updateReviewApplication,
upsertReviewMindMap, upsertReviewMindMap,
uploadReviewMindMap, uploadReviewMindMap,
} from '@/api/review' } from '@/api/review'
@@ -83,32 +79,6 @@ describe('review API', () => {
expect(mockedRequest.get).toHaveBeenCalledWith('/review/task/T001') expect(mockedRequest.get).toHaveBeenCalledWith('/review/task/T001')
}) })
it('review applications API calls expected endpoints', async () => {
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
mockedRequest.post.mockResolvedValue({ code: 200, data: { id: 1 } })
mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } })
mockedRequest.del.mockResolvedValue({ code: 200 })
await getReviewApplications('T001')
expect(mockedRequest.get).toHaveBeenCalledWith('/review/applications/T001')
await createReviewApplication({ taskNum: 'T001', title: 'Demo', status: 'TODO' })
expect(mockedRequest.post).toHaveBeenCalledWith('/review/applications', {
taskNum: 'T001',
title: 'Demo',
status: 'TODO',
})
await updateReviewApplication(1, { title: 'Demo 2', status: 'DOING' })
expect(mockedRequest.put).toHaveBeenCalledWith('/review/applications/1', {
title: 'Demo 2',
status: 'DOING',
})
await deleteReviewApplication(1)
expect(mockedRequest.del).toHaveBeenCalledWith('/review/applications/1')
})
it('review mind map API calls expected endpoints', async () => { it('review mind map API calls expected endpoints', async () => {
mockedRequest.get.mockResolvedValue({ code: 200, data: null }) mockedRequest.get.mockResolvedValue({ code: 200, data: null })
mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } }) mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } })
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/utils/request', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn(),
},
}))
import request from '@/utils/request'
import {
createTaskApplication,
deleteTaskApplication,
getTaskApplications,
updateTaskApplication,
} from '@/api/tasks'
const mockedRequest = vi.mocked(request)
describe('tasks API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('task applications API calls expected endpoints', async () => {
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
mockedRequest.post.mockResolvedValue({ code: 200, data: { id: 1 } })
mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } })
mockedRequest.del.mockResolvedValue({ code: 200 })
await getTaskApplications('T001')
expect(mockedRequest.get).toHaveBeenCalledWith('/tasks/T001/applications')
await createTaskApplication('T001', { title: 'Demo', status: 'TODO' })
expect(mockedRequest.post).toHaveBeenCalledWith('/tasks/T001/applications', {
title: 'Demo',
status: 'TODO',
})
await updateTaskApplication(1, { title: 'Demo 2', status: 'DOING' })
expect(mockedRequest.put).toHaveBeenCalledWith('/tasks/applications/1', {
title: 'Demo 2',
status: 'DOING',
})
await deleteTaskApplication(1)
expect(mockedRequest.del).toHaveBeenCalledWith('/tasks/applications/1')
})
})
-41
View File
@@ -23,17 +23,6 @@ export interface ReviewTaskStats {
avgEffectivenessRatio: number; avgEffectivenessRatio: number;
} }
export interface ReviewApplication {
id: number;
taskNum: string;
title: string;
description?: string;
resourceUrl?: string;
status: "TODO" | "DOING" | "DONE";
createdTime?: string;
lastModifiedTime?: string;
}
export interface ReviewMindMap { export interface ReviewMindMap {
id: number; id: number;
taskNum: string; taskNum: string;
@@ -77,36 +66,6 @@ export const getTaskReview = (taskNum: string) => {
return request.get(`/review/task/${taskNum}`); return request.get(`/review/task/${taskNum}`);
}; };
export const getReviewApplications = (taskNum: string) => {
return request.get(`/review/applications/${taskNum}`);
};
export const createReviewApplication = (payload: {
taskNum: string;
title: string;
description?: string;
resourceUrl?: string;
status?: ReviewApplication["status"];
}) => {
return request.post("/review/applications", payload);
};
export const updateReviewApplication = (
id: number,
payload: {
title: string;
description?: string;
resourceUrl?: string;
status?: ReviewApplication["status"];
},
) => {
return request.put(`/review/applications/${id}`, payload);
};
export const deleteReviewApplication = (id: number) => {
return request.del(`/review/applications/${id}`);
};
export const getReviewMindMap = (taskNum: string) => { export const getReviewMindMap = (taskNum: string) => {
return request.get(`/review/mind-map/${taskNum}`); return request.get(`/review/mind-map/${taskNum}`);
}; };
+63
View File
@@ -0,0 +1,63 @@
import request from "@/utils/request";
import type { ReviewMindMap } from "./review";
// ============ 标准思维导图 ============
export interface StandardMindMap {
id: number;
taskNum: string;
title: string;
content: string;
outline: string;
summary: string;
generator: "BUILTIN" | "AI" | "USER";
generatorVersion?: string;
sourceReportCount: number;
sourceFragmentCount: number;
generatedTime?: string;
createdTime?: string;
lastModifiedTime?: string;
}
export interface RecallRecord {
id: number;
taskNum: string;
standardMapId: number;
recallContent: string;
compareResult: string;
recallRatio: number;
matchedCount: number;
missedCount: number;
extraCount: number;
createdTime: string;
}
/** 获取/自动生成标准思维导图 */
export const getStandardMindMap = (taskNum: string) => {
return request.get(`/review/standard-mind-map/${taskNum}`);
};
/** 强制重新生成标准思维导图 */
export const regenerateStandardMindMap = (taskNum: string) => {
return request.post(`/review/standard-mind-map/${taskNum}/regenerate`);
};
/** 用户编辑标准思维导图(大纲文本) */
export const updateStandardMindMap = (taskNum: string, outline: string) => {
return request.put(`/review/standard-mind-map/${taskNum}`, { outline });
};
/** 用户提交回忆大纲,与标准导图对比 */
export const recallCompare = (taskNum: string, recallOutline: string) => {
return request.post(`/review/standard-mind-map/${taskNum}/recall`, { recallOutline });
};
/** 获取该任务的所有回忆对比记录 */
export const listRecallRecords = (taskNum: string) => {
return request.get(`/review/standard-mind-map/${taskNum}/recall-records`);
};
/** 获取单条回忆对比记录 */
export const getRecallRecord = (recordId: number) => {
return request.get(`/review/standard-mind-map/recall-records/${recordId}`);
};
+44
View File
@@ -0,0 +1,44 @@
import request from "@/utils/request";
export interface TaskApplication {
id: number;
taskNum: string;
title: string;
description?: string;
resourceUrl?: string;
status: "TODO" | "DOING" | "DONE";
createdTime?: string;
lastModifiedTime?: string;
}
export const getTaskApplications = (taskNum: string) => {
return request.get(`/tasks/${taskNum}/applications`);
};
export const createTaskApplication = (
taskNum: string,
payload: {
title: string;
description?: string;
resourceUrl?: string;
status?: TaskApplication["status"];
},
) => {
return request.post(`/tasks/${taskNum}/applications`, payload);
};
export const updateTaskApplication = (
id: number,
payload: {
title: string;
description?: string;
resourceUrl?: string;
status?: TaskApplication["status"];
},
) => {
return request.put(`/tasks/applications/${id}`, payload);
};
export const deleteTaskApplication = (id: number) => {
return request.del(`/tasks/applications/${id}`);
};
+8 -1
View File
@@ -94,7 +94,7 @@ onMounted(() => {
</el-table-column> </el-table-column>
<el-table-column prop="reportCount" label="学习报告数" min-width="120" /> <el-table-column prop="reportCount" label="学习报告数" min-width="120" />
<el-table-column prop="fragmentCount" label="学习残片数" min-width="120" /> <el-table-column prop="fragmentCount" label="学习残片数" min-width="120" />
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
text text
@@ -104,6 +104,13 @@ onMounted(() => {
> >
查看记录 查看记录
</el-button> </el-button>
<el-button
text
type="primary"
@click.stop="router.push(`/review/recall/${row.taskNum}`)"
>
回忆复习
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
+4 -188
View File
@@ -2,16 +2,11 @@
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { import {
createReviewApplication,
deleteReviewApplication,
getFragmentDetail, getFragmentDetail,
getReportDetail, getReportDetail,
getReviewApplications,
getReviewMindMap, getReviewMindMap,
getTaskReview, getTaskReview,
updateReviewApplication,
uploadReviewMindMap, uploadReviewMindMap,
type ReviewApplication,
type ReviewFeedItem, type ReviewFeedItem,
type ReviewMindMap, type ReviewMindMap,
} from "@/api/review"; } from "@/api/review";
@@ -34,21 +29,6 @@ const isEditing = ref(false);
const editContent = ref(""); const editContent = ref("");
const saving = ref(false); const saving = ref(false);
const applications = ref<ReviewApplication[]>([]);
const applicationSaving = ref(false);
const editingApplicationId = ref<number | null>(null);
const applicationForm = ref<{
title: string;
description: string;
resourceUrl: string;
status: ReviewApplication["status"];
}>({
title: "",
description: "",
resourceUrl: "",
status: "TODO",
});
const mindMap = ref<ReviewMindMap | null>(null); const mindMap = ref<ReviewMindMap | null>(null);
const mindMapUploading = ref(false); const mindMapUploading = ref(false);
@@ -68,12 +48,6 @@ const taskSessions = ref<{
fragments: ReviewFeedItem[]; fragments: ReviewFeedItem[];
}[]>([]); }[]>([]);
const statusText: Record<ReviewApplication["status"], string> = {
TODO: "待应用",
DOING: "进行中",
DONE: "已完成",
};
type MindMapNode = { type MindMapNode = {
title?: string; title?: string;
notes?: string; notes?: string;
@@ -123,16 +97,6 @@ const formatRatio = (ratio?: number): string => {
return `${(ratio * 100).toFixed(1)}%`; return `${(ratio * 100).toFixed(1)}%`;
}; };
const resetApplicationForm = () => {
editingApplicationId.value = null;
applicationForm.value = {
title: "",
description: "",
resourceUrl: "",
status: "TODO",
};
};
const loadTaskHistory = async (tn: string) => { const loadTaskHistory = async (tn: string) => {
try { try {
const res = await getTaskReview(tn); const res = await getTaskReview(tn);
@@ -165,9 +129,6 @@ const loadTaskHistory = async (tn: string) => {
}; };
const loadReviewExtensions = async (tn: string) => { const loadReviewExtensions = async (tn: string) => {
const applicationsRes = await getReviewApplications(tn).catch(() => null);
applications.value = applicationsRes?.data || [];
const mindMapRes = await getReviewMindMap(tn).catch(() => null); const mindMapRes = await getReviewMindMap(tn).catch(() => null);
const loadedMindMap: ReviewMindMap | null = mindMapRes?.data || null; const loadedMindMap: ReviewMindMap | null = mindMapRes?.data || null;
mindMap.value = loadedMindMap; mindMap.value = loadedMindMap;
@@ -182,7 +143,6 @@ const loadDetail = async () => {
taskNum.value = ""; taskNum.value = "";
sessionInfo.value = null; sessionInfo.value = null;
taskSessions.value = []; taskSessions.value = [];
applications.value = [];
mindMap.value = null; mindMap.value = null;
try { try {
let res; let res;
@@ -260,58 +220,6 @@ const saveEdit = async () => {
} }
}; };
const saveApplication = async () => {
if (!taskNum.value) {
ElMessage.warning("未找到关联任务");
return;
}
if (!applicationForm.value.title.trim()) {
ElMessage.warning("应用项目标题不能为空");
return;
}
applicationSaving.value = true;
try {
const payload = {
title: applicationForm.value.title,
description: applicationForm.value.description,
resourceUrl: applicationForm.value.resourceUrl,
status: applicationForm.value.status,
};
if (editingApplicationId.value) {
await updateReviewApplication(editingApplicationId.value, payload);
ElMessage.success("应用场景已更新");
} else {
await createReviewApplication({ taskNum: taskNum.value, ...payload });
ElMessage.success("应用场景已添加");
}
resetApplicationForm();
await loadReviewExtensions(taskNum.value);
} finally {
applicationSaving.value = false;
}
};
const editApplication = (item: ReviewApplication) => {
editingApplicationId.value = item.id;
applicationForm.value = {
title: item.title || "",
description: item.description || "",
resourceUrl: item.resourceUrl || "",
status: item.status || "TODO",
};
};
const removeApplication = async (item: ReviewApplication) => {
await deleteReviewApplication(item.id);
ElMessage.success("应用场景已删除");
if (editingApplicationId.value === item.id) {
resetApplicationForm();
}
if (taskNum.value) {
await loadReviewExtensions(taskNum.value);
}
};
const handleMindMapFileChange = async (uploadFile: any) => { const handleMindMapFileChange = async (uploadFile: any) => {
if (!taskNum.value) { if (!taskNum.value) {
ElMessage.warning("未找到关联任务"); ElMessage.warning("未找到关联任务");
@@ -402,53 +310,6 @@ watch(
</article> </article>
<section class="review-extension-grid" v-if="taskNum"> <section class="review-extension-grid" v-if="taskNum">
<article class="surface-card extension-card">
<div class="extension-head">
<h3>应用场景</h3>
<el-tag size="small" type="success" effect="plain">{{ applications.length }}</el-tag>
</div>
<div class="application-list" v-if="applications.length > 0">
<div class="application-item" v-for="item in applications" :key="item.id">
<div class="application-main">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">{{ statusText[item.status] || item.status }}</el-tag>
</div>
<p v-if="item.description">{{ item.description }}</p>
<el-link v-if="item.resourceUrl" :href="item.resourceUrl" target="_blank" type="primary">
{{ item.resourceUrl }}
</el-link>
<div class="item-actions">
<el-button text type="primary" size="small" @click="editApplication(item)">编辑</el-button>
<el-button text type="danger" size="small" @click="removeApplication(item)">删除</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂无应用场景" :image-size="64" />
<div class="application-form">
<el-input v-model="applicationForm.title" placeholder="应用项目" />
<el-input
v-model="applicationForm.description"
type="textarea"
:rows="3"
placeholder="应用描述"
/>
<el-input v-model="applicationForm.resourceUrl" placeholder="相关链接" />
<div class="form-row">
<el-select v-model="applicationForm.status" placeholder="状态">
<el-option label="待应用" value="TODO" />
<el-option label="进行中" value="DOING" />
<el-option label="已完成" value="DONE" />
</el-select>
<el-button type="success" :loading="applicationSaving" @click="saveApplication">
{{ editingApplicationId ? "更新" : "添加" }}
</el-button>
<el-button v-if="editingApplicationId" @click="resetApplicationForm">取消</el-button>
</div>
</div>
</article>
<article class="surface-card extension-card"> <article class="surface-card extension-card">
<div class="extension-head"> <div class="extension-head">
<h3>思维导图</h3> <h3>思维导图</h3>
@@ -467,6 +328,9 @@ watch(
> >
<el-button type="success" :loading="mindMapUploading">上传导图文件</el-button> <el-button type="success" :loading="mindMapUploading">上传导图文件</el-button>
</el-upload> </el-upload>
<el-button type="primary" size="small" @click="router.push(`/review/recall/${taskNum}`)">
回忆复习
</el-button>
<el-tag v-if="mindMap?.fileFormat" size="small" effect="plain"> <el-tag v-if="mindMap?.fileFormat" size="small" effect="plain">
{{ mindMap.fileFormat }} {{ mindMap.fileFormat }}
</el-tag> </el-tag>
@@ -605,7 +469,7 @@ watch(
.review-extension-grid { .review-extension-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 14px; gap: 14px;
} }
@@ -627,48 +491,6 @@ watch(
font-size: 16px; font-size: 16px;
} }
.application-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.application-item {
padding: 12px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: #fbfefc;
}
.application-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.application-main strong {
min-width: 0;
color: var(--text-primary);
word-break: break-word;
}
.application-item p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 13px;
word-break: break-word;
}
.item-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 8px;
}
.application-form,
.mind-map-form { .mind-map-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -740,12 +562,6 @@ watch(
color: var(--text-primary); color: var(--text-primary);
} }
.form-row {
display: flex;
align-items: center;
gap: 10px;
}
/* 历史记录卡片 */ /* 历史记录卡片 */
.history-card { .history-card {
padding: 20px 22px; padding: 20px 22px;
+663
View File
@@ -0,0 +1,663 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getStandardMindMap,
regenerateStandardMindMap,
recallCompare,
updateStandardMindMap,
listRecallRecords,
type StandardMindMap,
type RecallRecord,
} from "@/api/standardMindMap";
import { ElMessage, ElMessageBox } from "element-plus";
const route = useRoute();
const router = useRouter();
const taskNum = computed(() => route.params.taskNum as string);
const loading = ref(false);
const standard = ref<StandardMindMap | null>(null);
const recallOutline = ref("");
const comparing = ref(false);
const lastResult = ref<any>(null);
const hasCompared = ref(false);
// 标准导图展开/折叠(默认折叠,防止剧透)
const standardExpanded = ref(false);
// 编辑标准导图
const editingStandard = ref(false);
const editOutline = ref("");
const savingStandard = ref(false);
// 回忆历史
const historyRecords = ref<RecallRecord[]>([]);
const showHistory = ref(false);
// 格式化覆盖率
const ratioPercent = computed(() => {
if (!lastResult.value?.recallRatio) return "0%";
return `${(lastResult.value.recallRatio * 100).toFixed(1)}%`;
});
// 加载数据
const loadData = async () => {
loading.value = true;
try {
const res = await getStandardMindMap(taskNum.value);
standard.value = res?.data || null;
} catch (e: any) {
standard.value = null;
} finally {
loading.value = false;
}
};
// 重新生成
const handleRegenerate = async () => {
try {
await ElMessageBox.confirm("重新生成将丢弃用户编辑的内容,确定?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
loading.value = true;
const res = await regenerateStandardMindMap(taskNum.value);
standard.value = res?.data || null;
ElMessage.success("标准思维导图已重新生成");
} catch {
// 用户取消或接口错误
} finally {
loading.value = false;
}
};
// 提交回忆对比
const handleRecallCompare = async () => {
if (!recallOutline.value.trim()) {
ElMessage.warning("请输入回忆内容大纲");
return;
}
comparing.value = true;
try {
const res = await recallCompare(taskNum.value, recallOutline.value);
standard.value = res?.data || null;
hasCompared.value = true;
// 解析对比结果
if (standard.value) {
const recordRes = await listRecallRecords(taskNum.value);
const records: RecallRecord[] = recordRes?.data || [];
if (records.length > 0) {
const latest = records[0];
try {
lastResult.value = JSON.parse(latest.compareResult);
} catch {
lastResult.value = null;
}
historyRecords.value = records;
}
}
ElMessage.success("回忆对比完成");
} catch (e: any) {
ElMessage.error(e.message || "对比失败");
} finally {
comparing.value = false;
}
};
// 编辑标准导图
const startEditStandard = () => {
editOutline.value = standard.value?.outline || "";
editingStandard.value = true;
};
const saveStandardEdit = async () => {
if (!editOutline.value.trim()) {
ElMessage.warning("大纲不可为空");
return;
}
savingStandard.value = true;
try {
const res = await updateStandardMindMap(taskNum.value, editOutline.value);
standard.value = res?.data || null;
editingStandard.value = false;
ElMessage.success("标准思维导图已更新");
} catch (e: any) {
ElMessage.error(e.message || "保存失败");
} finally {
savingStandard.value = false;
}
};
const cancelStandardEdit = () => {
editingStandard.value = false;
editOutline.value = "";
};
// 查看回忆历史
const loadHistory = async () => {
try {
const res = await listRecallRecords(taskNum.value);
historyRecords.value = res?.data || [];
showHistory.value = true;
} catch {
ElMessage.error("加载历史记录失败");
}
};
// 选择历史记录查看结果
const selectHistoryRecord = (record: RecallRecord) => {
try {
lastResult.value = JSON.parse(record.compareResult);
// 重新设置 recallOutline 为历史内容
recallOutline.value = record.recallContent;
hasCompared.value = true;
} catch {
ElMessage.error("解析对比记录失败");
}
};
// 跳转到复习详情
const goToReviewDetail = (sourceType: string, sourceId: number) => {
router.push(`/review/detail/${sourceType.toLowerCase()}/${sourceId}`);
};
// 渲染对比结果树
const renderCompareTree = (nodes: any[], level: number = 0): string => {
if (!nodes || nodes.length === 0) return "";
return nodes
.map((node: any) => {
const notes = node.notes || "";
const isMatched = notes.startsWith("MATCHED|");
const isMissed = notes.startsWith("MISSED|");
const marker = isMatched ? "✅ " : isMissed ? "❌ " : " ";
const title = node.title || "(未命名)";
const indent = " ".repeat(level);
const childrenHtml = renderCompareTree(node.children, level + 1);
return `${indent}${marker}${title}\n${childrenHtml}`;
})
.join("");
};
const resultText = computed(() => {
if (!lastResult.value?.matchedTree) return "";
const tree = lastResult.value.matchedTree;
// 根节点标题
let text = `${tree.title || "思维导图"}\n\n`;
text += renderCompareTree(tree.children || tree.nodes || [], 1);
return text;
});
// 获取遗漏项列表
const missedItems = computed(() => {
if (!lastResult.value?.matchedTree) return [];
const items: { title: string; sourceType: string; sourceId: number }[] = [];
const walk = (node: any) => {
const notes = node.notes || "";
if (notes.startsWith("MISSED|")) {
items.push({
title: node.title || "",
sourceType: node.sourceType || "",
sourceId: node.sourceId,
});
}
(node.children || []).forEach(walk);
};
walk(lastResult.value.matchedTree);
return items;
});
// 额外节点列表
const extraNodes = computed(() => {
return lastResult.value?.extraNodes || [];
});
onMounted(() => {
loadData();
});
</script>
<template>
<section class="recall-page">
<!-- 顶部操作栏 -->
<div class="page-header">
<el-button size="small" @click="router.back()"> 返回</el-button>
<h2>回忆复习</h2>
<el-button size="small" @click="loadHistory" v-if="!showHistory">
历史记录
</el-button>
</div>
<!-- 统计卡片 -->
<div class="stats-card surface-card" v-if="hasCompared && lastResult">
<div class="stat-item">
<span class="stat-label">回忆覆盖率</span>
<strong class="stat-value highlight">{{ ratioPercent }}</strong>
</div>
<div class="stat-item">
<span class="stat-label"> 回忆命中</span>
<strong class="stat-value matched">{{ lastResult.matchedCount }}</strong>
</div>
<div class="stat-item">
<span class="stat-label"> 遗漏</span>
<strong class="stat-value missed">{{ lastResult.missedCount }}</strong>
</div>
<div class="stat-item">
<span class="stat-label"> 额外</span>
<strong class="stat-value extra">{{ lastResult.extraCount }}</strong>
</div>
</div>
<!-- 记忆历史 -->
<article class="surface-card" v-if="showHistory && historyRecords.length > 0">
<h3>回忆历史</h3>
<div class="history-list">
<div
v-for="record in historyRecords"
:key="record.id"
class="history-item"
@click="selectHistoryRecord(record)"
>
<span class="history-time">{{ record.createdTime?.replace("T", " ")?.substring(0, 16) }}</span>
<span class="history-ratio">覆盖率 {{ (record.recallRatio * 100).toFixed(1) }}%</span>
<span class="history-detail">{{ record.matchedCount }} {{ record.missedCount }} {{ record.extraCount }}</span>
</div>
</div>
<el-divider />
</article>
<!-- 两大块回忆输入区 + 标准导图 -->
<div class="recall-layout">
<!-- 左侧回忆输入 -->
<article class="surface-card recall-panel">
<h3>🧠 你的回忆</h3>
<p class="panel-hint">
凭记忆写下该任务的知识点大纲缩进表示层级写完后点击"提交对比"
</p>
<el-input
v-model="recallOutline"
type="textarea"
:rows="12"
placeholder="Java 并发编程
├─ 线程基础
│ ├─ 创建线程
│ └─ 线程状态
├─ 锁机制
│ ├─ Synchronized
│ └─ ReentrantLock"
/>
<div class="recall-actions">
<el-button type="primary" :loading="comparing" @click="handleRecallCompare">
提交对比
</el-button>
<el-button v-if="hasCompared" @click="recallOutline = ''">
清空重写
</el-button>
</div>
<!-- 对比结果 -->
<div v-if="hasCompared && resultText" class="compare-result">
<h4>📊 对比结果</h4>
<pre class="compare-tree">{{ resultText }}</pre>
</div>
</article>
<!-- 右侧标准导图 -->
<article class="surface-card standard-panel">
<div class="standard-header">
<h3>📖 标准思维导图</h3>
<div class="standard-actions">
<el-button size="small" @click="standardExpanded = !standardExpanded">
{{ standardExpanded ? "折叠" : "展开" }}
</el-button>
<el-button
v-if="standardExpanded"
size="small"
type="warning"
@click="handleRegenerate"
>
重新生成
</el-button>
<el-button
v-if="standardExpanded && !editingStandard"
size="small"
type="primary"
@click="startEditStandard"
>
编辑
</el-button>
</div>
</div>
<div v-if="!standard" class="empty-state">
<el-empty description="暂无标准思维导图" :image-size="64">
<el-button type="primary" :loading="loading" @click="loadData">
生成
</el-button>
</el-empty>
</div>
<div v-else-if="editingStandard" class="edit-area">
<p class="panel-hint">编辑大纲文本保存后标准导图将被更新</p>
<el-input v-model="editOutline" type="textarea" :rows="12" />
<p v-if="standard.summary" class="generator-info">{{ standard.summary }}</p>
<div class="edit-actions">
<el-button @click="cancelStandardEdit">取消</el-button>
<el-button type="primary" :loading="savingStandard" @click="saveStandardEdit">
保存更新
</el-button>
</div>
</div>
<div v-else-if="standardExpanded" class="standard-content">
<div class="standard-meta">
<span>来源{{ standard.generator }}</span>
<span>参考{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
</div>
<pre class="standard-outline">{{ standard.outline || standard.content }}</pre>
<p v-if="standard.summary" class="generator-info">{{ standard.summary }}</p>
</div>
<div v-else class="collapsed-hint">
<p>标准导图已生成展开后可查看或编辑建议先凭记忆写回忆大纲再对照</p>
</div>
</article>
</div>
<!-- 遗漏项 + 额外项详情 -->
<div v-if="hasCompared && lastResult" class="detail-section">
<!-- 遗漏项 -->
<article class="surface-card" v-if="missedItems.length > 0">
<h3> 遗漏的知识点</h3>
<div class="missed-list">
<div v-for="(item, idx) in missedItems" :key="idx" class="missed-item">
<span class="missed-title">{{ item.title }}</span>
<el-button
v-if="item.sourceType && item.sourceId"
text
type="primary"
size="small"
@click="goToReviewDetail(item.sourceType, item.sourceId)"
>
查看原文
</el-button>
</div>
</div>
</article>
<!-- 额外项 -->
<article class="surface-card" v-if="extraNodes.length > 0">
<h3> 你额外回忆到的知识点</h3>
<div class="extra-list">
<div v-for="(item, idx) in extraNodes" :key="idx" class="extra-item">
{{ item.title }}
</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.recall-page {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
}
.page-header h2 {
margin: 0;
flex: 1;
color: var(--green-900);
font-size: 18px;
}
.surface-card {
padding: 20px 22px;
background: #fff;
border-radius: 8px;
border: 1px solid var(--border-soft, #e0e0e0);
}
.stats-card {
display: flex;
gap: 24px;
flex-wrap: wrap;
background: linear-gradient(135deg, #f1f8e9 0%, #e8f5e9 100%);
}
.stat-item {
min-width: 100px;
}
.stat-label {
display: block;
font-size: 12px;
color: var(--text-secondary, #666);
}
.stat-value {
display: block;
font-size: 28px;
margin-top: 4px;
}
.highlight { color: #2e7d32; }
.matched { color: #2e7d32; }
.missed { color: #c62828; }
.extra { color: #1565c0; }
.recall-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
@media (max-width: 900px) {
.recall-layout {
grid-template-columns: 1fr;
}
}
.recall-panel h3,
.standard-panel h3 {
margin: 0 0 8px;
color: var(--green-900);
font-size: 16px;
}
.panel-hint {
font-size: 13px;
color: var(--text-secondary, #666);
margin: 0 0 12px;
line-height: 1.5;
}
.recall-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.standard-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.standard-header h3 {
margin: 0;
}
.standard-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.standard-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
margin-bottom: 10px;
flex-wrap: wrap;
}
.standard-outline {
white-space: pre-wrap;
font-size: 13px;
line-height: 1.7;
color: var(--text-primary, #333);
background: #fafafa;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-soft, #e0e0e0);
max-height: 400px;
overflow-y: auto;
word-break: break-word;
}
.generator-info {
font-size: 12px;
color: var(--text-secondary, #666);
margin: 8px 0 0;
}
.collapsed-hint {
color: var(--text-secondary, #666);
font-size: 14px;
text-align: center;
padding: 20px;
}
.empty-state {
display: flex;
justify-content: center;
}
.compare-result {
margin-top: 20px;
}
.compare-result h4 {
margin: 0 0 10px;
color: var(--green-800);
font-size: 15px;
}
.compare-tree {
white-space: pre-wrap;
font-size: 13px;
line-height: 1.7;
background: #fafafa;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-soft, #e0e0e0);
max-height: 500px;
overflow-y: auto;
}
.edit-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.detail-section {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-section h3 {
margin: 0 0 12px;
color: var(--green-900);
font-size: 15px;
}
.missed-list,
.extra-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.missed-item,
.extra-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #fafafa;
font-size: 14px;
}
.missed-item {
border-left: 3px solid #c62828;
}
.extra-item {
border-left: 3px solid #1565c0;
}
.missed-title {
flex: 1;
}
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.history-item:hover {
background: #f1f8e9;
}
.history-time {
color: var(--text-secondary, #666);
min-width: 120px;
}
.history-ratio {
font-weight: 600;
color: var(--green-800);
min-width: 80px;
}
.history-detail {
color: var(--text-secondary, #666);
}
</style>
+135
View File
@@ -4,6 +4,11 @@ import request from "@/utils/request";
import router from "@/router"; import router from "@/router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { getReviewTaskStatsByTask } from "@/api/review"; import { getReviewTaskStatsByTask } from "@/api/review";
import {
getTaskApplications,
updateTaskApplication,
type TaskApplication,
} from "@/api/tasks";
interface TaskItem { interface TaskItem {
title: string; title: string;
@@ -27,6 +32,14 @@ interface TaskItem {
const tasks = ref<TaskItem[]>([]); const tasks = ref<TaskItem[]>([]);
const selectedTaskId = ref<number | null>(null); const selectedTaskId = ref<number | null>(null);
const loading = ref(false); const loading = ref(false);
const taskApplications = ref<TaskApplication[]>([]);
const applicationsLoading = ref(false);
const applicationStatusOptions: { label: string; value: TaskApplication["status"] }[] = [
{ label: "待应用", value: "TODO" },
{ label: "进行中", value: "DOING" },
{ label: "已完成", value: "DONE" },
];
const selectedTask = computed(() => const selectedTask = computed(() =>
tasks.value.find((item) => item.taskId === selectedTaskId.value) || null tasks.value.find((item) => item.taskId === selectedTaskId.value) || null
@@ -62,6 +75,36 @@ async function loadTaskStats(taskNum: string) {
} }
} }
async function loadTaskApplications(taskNum: string) {
applicationsLoading.value = true;
try {
const res = await getTaskApplications(taskNum);
taskApplications.value = res?.data || [];
} catch (error: any) {
taskApplications.value = [];
ElMessage.error(error?.message || "应用场景加载失败");
} finally {
applicationsLoading.value = false;
}
}
const changeApplicationStatus = async (item: TaskApplication) => {
try {
await updateTaskApplication(item.id, {
title: item.title,
description: item.description,
resourceUrl: item.resourceUrl,
status: item.status,
});
ElMessage.success("应用场景状态已更新");
} catch (error: any) {
ElMessage.error(error?.message || "状态更新失败");
if (selectedTask.value) {
await loadTaskApplications(selectedTask.value.taskNum);
}
}
};
const fetchTasks = async () => { const fetchTasks = async () => {
loading.value = true; loading.value = true;
try { try {
@@ -91,10 +134,12 @@ const fetchTasks = async () => {
selectedTaskId.value = nextSelectedTaskId; selectedTaskId.value = nextSelectedTaskId;
if (targetTask && shouldLoadStats) { if (targetTask && shouldLoadStats) {
await loadTaskStats(targetTask.taskNum); await loadTaskStats(targetTask.taskNum);
await loadTaskApplications(targetTask.taskNum);
} }
} catch (error: any) { } catch (error: any) {
tasks.value = []; tasks.value = [];
selectedTaskId.value = null; selectedTaskId.value = null;
taskApplications.value = [];
ElMessage.error(error?.message || "任务列表加载失败,请重试"); ElMessage.error(error?.message || "任务列表加载失败,请重试");
} finally { } finally {
loading.value = false; loading.value = false;
@@ -105,6 +150,9 @@ watch(selectedTaskId, (newId) => {
const task = tasks.value.find(t => t.taskId === newId); const task = tasks.value.find(t => t.taskId === newId);
if (task) { if (task) {
loadTaskStats(task.taskNum); loadTaskStats(task.taskNum);
loadTaskApplications(task.taskNum);
} else {
taskApplications.value = [];
} }
}); });
@@ -229,6 +277,39 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
<div class="detail-block" v-loading="applicationsLoading">
<p class="label">应用场景</p>
<div v-if="taskApplications.length > 0" class="application-list">
<div
v-for="item in taskApplications"
:key="item.id"
class="application-item"
>
<div class="application-main">
<strong>{{ item.title }}</strong>
<el-select
v-model="item.status"
size="small"
class="application-status"
@change="changeApplicationStatus(item)"
>
<el-option
v-for="option in applicationStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
<p v-if="item.description">{{ item.description }}</p>
<el-link v-if="item.resourceUrl" :href="item.resourceUrl" target="_blank" type="primary">
{{ item.resourceUrl }}
</el-link>
</div>
</div>
<p v-else class="empty-text">暂未设置应用场景</p>
</div>
</template> </template>
<el-empty v-else description="暂无任务,请先创建任务" /> <el-empty v-else description="暂无任务,请先创建任务" />
</article> </article>
@@ -365,6 +446,51 @@ onMounted(() => {
color: var(--text-primary); color: var(--text-primary);
} }
.application-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.application-item {
padding: 12px 0;
border-bottom: 1px solid var(--border-soft);
}
.application-item:first-child {
padding-top: 0;
}
.application-item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.application-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.application-main strong {
min-width: 0;
color: var(--text-primary);
word-break: break-word;
}
.application-status {
width: 112px;
flex: 0 0 auto;
}
.application-item p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 13px;
word-break: break-word;
}
.action-card { .action-card {
padding: 16px; padding: 16px;
display: flex; display: flex;
@@ -404,5 +530,14 @@ onMounted(() => {
.action-card { .action-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.application-main {
align-items: flex-start;
flex-direction: column;
}
.application-status {
width: 100%;
}
} }
</style> </style>
+232
View File
@@ -3,13 +3,22 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
import request from "@/utils/request"; import request from "@/utils/request";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import {
createTaskApplication,
deleteTaskApplication,
getTaskApplications,
updateTaskApplication,
type TaskApplication,
} from "@/api/tasks";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const taskId = route.params.taskId ? Number(route.params.taskId) : null; const taskId = route.params.taskId ? Number(route.params.taskId) : null;
const buttonName = route.meta.buttonText as string; const buttonName = route.meta.buttonText as string;
const isUpdateMode = computed(() => route.meta.action === "update");
const taskNum = ref("");
const taskName = ref(""); const taskName = ref("");
const taskDescription = ref(""); const taskDescription = ref("");
const materialURL = ref(""); const materialURL = ref("");
@@ -22,6 +31,102 @@ const priority = ref({
}); });
const priorityOptions = [0, 1, 2, 3, 4, 5]; const priorityOptions = [0, 1, 2, 3, 4, 5];
const applicationStatusOptions: { label: string; value: TaskApplication["status"] }[] = [
{ label: "待应用", value: "TODO" },
{ label: "进行中", value: "DOING" },
{ label: "已完成", value: "DONE" },
];
const applications = ref<TaskApplication[]>([]);
const applicationLoading = ref(false);
const applicationSaving = ref(false);
const editingApplicationId = ref<number | null>(null);
const applicationForm = ref<{
title: string;
description: string;
resourceUrl: string;
status: TaskApplication["status"];
}>({
title: "",
description: "",
resourceUrl: "",
status: "TODO",
});
const resetApplicationForm = () => {
editingApplicationId.value = null;
applicationForm.value = {
title: "",
description: "",
resourceUrl: "",
status: "TODO",
};
};
const loadApplications = async (targetTaskNum: string) => {
applicationLoading.value = true;
try {
const res = await getTaskApplications(targetTaskNum);
applications.value = res?.data || [];
} catch (error: any) {
applications.value = [];
ElMessage.error(error?.message || "应用场景加载失败");
} finally {
applicationLoading.value = false;
}
};
const saveApplication = async () => {
if (!taskNum.value) {
ElMessage.warning("任务编号未加载,暂不能维护应用场景");
return;
}
if (!applicationForm.value.title.trim()) {
ElMessage.warning("应用项目标题不能为空");
return;
}
applicationSaving.value = true;
try {
const payload = {
title: applicationForm.value.title,
description: applicationForm.value.description,
resourceUrl: applicationForm.value.resourceUrl,
status: applicationForm.value.status,
};
if (editingApplicationId.value) {
await updateTaskApplication(editingApplicationId.value, payload);
ElMessage.success("应用场景已更新");
} else {
await createTaskApplication(taskNum.value, payload);
ElMessage.success("应用场景已添加");
}
resetApplicationForm();
await loadApplications(taskNum.value);
} finally {
applicationSaving.value = false;
}
};
const editApplication = (item: TaskApplication) => {
editingApplicationId.value = item.id;
applicationForm.value = {
title: item.title || "",
description: item.description || "",
resourceUrl: item.resourceUrl || "",
status: item.status || "TODO",
};
};
const removeApplication = async (item: TaskApplication) => {
await deleteTaskApplication(item.id);
ElMessage.success("应用场景已删除");
if (editingApplicationId.value === item.id) {
resetApplicationForm();
}
if (taskNum.value) {
await loadApplications(taskNum.value);
}
};
const createTask = () => { const createTask = () => {
request request
@@ -46,6 +151,7 @@ const loadTask = () => {
request.get(`/tasks/${taskId}`, {}).then((result) => { request.get(`/tasks/${taskId}`, {}).then((result) => {
if (result.code === 200) { if (result.code === 200) {
const data = result.data; const data = result.data;
taskNum.value = data.taskNum || "";
taskName.value = data.taskName; taskName.value = data.taskName;
taskDescription.value = data.taskDescription; taskDescription.value = data.taskDescription;
materialURL.value = data.materialURL || data.materialUrl || ""; materialURL.value = data.materialURL || data.materialUrl || "";
@@ -56,6 +162,9 @@ const loadTask = () => {
futureValue: data.futureValue, futureValue: data.futureValue,
subjectivePriority: data.subjectivePriority, subjectivePriority: data.subjectivePriority,
}; };
if (taskNum.value) {
loadApplications(taskNum.value);
}
} else { } else {
ElMessage.error(`任务加载失败:${result.message}`); ElMessage.error(`任务加载失败:${result.message}`);
} }
@@ -169,6 +278,54 @@ const isMobile = computed(() => screenWidth.value <= 900);
</div> </div>
</div> </div>
<div class="group" v-if="isUpdateMode" v-loading="applicationLoading">
<h3>应用场景</h3>
<div class="application-list" v-if="applications.length > 0">
<div class="application-item" v-for="item in applications" :key="item.id">
<div class="application-main">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">
{{ applicationStatusOptions.find(option => option.value === item.status)?.label || item.status }}
</el-tag>
</div>
<p v-if="item.description">{{ item.description }}</p>
<el-link v-if="item.resourceUrl" :href="item.resourceUrl" target="_blank" type="primary">
{{ item.resourceUrl }}
</el-link>
<div class="application-actions">
<el-button text type="primary" size="small" @click="editApplication(item)">编辑</el-button>
<el-button text type="danger" size="small" @click="removeApplication(item)">删除</el-button>
</div>
</div>
</div>
<p v-else class="empty-text">暂未设置应用场景</p>
<div class="application-form">
<el-input v-model="applicationForm.title" placeholder="应用项目" />
<el-input
v-model="applicationForm.description"
type="textarea"
:rows="3"
placeholder="应用描述"
/>
<el-input v-model="applicationForm.resourceUrl" placeholder="相关链接" />
<div class="application-form-row">
<el-select v-model="applicationForm.status" placeholder="状态">
<el-option
v-for="option in applicationStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button type="success" :loading="applicationSaving" @click="saveApplication">
{{ editingApplicationId ? "更新" : "添加" }}
</el-button>
<el-button v-if="editingApplicationId" @click="resetApplicationForm">取消</el-button>
</div>
</div>
</div>
<div class="action-row"> <div class="action-row">
<el-button type="success" size="large" @click="submitTask">{{ buttonName }}</el-button> <el-button type="success" size="large" @click="submitTask">{{ buttonName }}</el-button>
<el-button size="large" @click="cancelTask">取消</el-button> <el-button size="large" @click="cancelTask">取消</el-button>
@@ -207,6 +364,67 @@ const isMobile = computed(() => screenWidth.value <= 900);
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.application-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.application-item {
padding: 12px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: #fbfefc;
}
.application-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.application-main strong {
min-width: 0;
color: var(--text-primary);
word-break: break-word;
}
.application-item p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 13px;
word-break: break-word;
}
.application-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 8px;
}
.application-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.application-form-row {
display: grid;
grid-template-columns: minmax(160px, 220px) auto auto;
gap: 10px;
align-items: center;
justify-content: start;
}
.empty-text {
margin: 0 0 12px;
color: var(--text-secondary);
font-size: 13px;
}
.action-row { .action-row {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
@@ -226,5 +444,19 @@ const isMobile = computed(() => screenWidth.value <= 900);
width: 100%; width: 100%;
margin: 0; margin: 0;
} }
.application-main {
align-items: flex-start;
flex-direction: column;
}
.application-form-row {
grid-template-columns: 1fr;
}
.application-form-row .el-button {
width: 100%;
margin: 0;
}
} }
</style> </style>
+5
View File
@@ -32,6 +32,11 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/components/ReviewDetail.vue"), component: () => import("@/components/ReviewDetail.vue"),
meta: { requiresAuth: true, title: "复习详情" }, meta: { requiresAuth: true, title: "复习详情" },
}, },
{
path: "/review/recall/:taskNum",
component: () => import("@/components/ReviewRecall.vue"),
meta: { requiresAuth: true, title: "回忆复习" },
},
{ {
path: "/start-task/:taskNum", path: "/start-task/:taskNum",
name: "start-task", name: "start-task",