feat(复习):回忆复习页面与入口
- 新增 ReviewRecall.vue 回忆复习页面(双栏布局) - 左栏:回忆大纲输入区、对比结果展示(✅❌着色树) - 右栏:标准导图(默认折叠)、编辑、重新生成 - 遗漏项列表,可点击回看原文 - 回忆历史记录查询 - api/standardMindMap.ts:标准导图与回忆对比 API - router:新增 /review/recall/:taskNum 路由 - Review.vue 行操作中增加回忆复习按钮 - ReviewDetail.vue 思维导图区增加回忆复习入口
This commit is contained in:
@@ -11,17 +11,13 @@ vi.mock('@/utils/request', () => ({
|
||||
|
||||
import request from '@/utils/request'
|
||||
import {
|
||||
createReviewApplication,
|
||||
deleteReviewApplication,
|
||||
getFragmentDetail,
|
||||
getReportDetail,
|
||||
getReviewApplications,
|
||||
getReviewFeed,
|
||||
getReviewMindMap,
|
||||
getReviewTaskStats,
|
||||
getReviewTaskStatsByTask,
|
||||
getTaskReview,
|
||||
updateReviewApplication,
|
||||
upsertReviewMindMap,
|
||||
uploadReviewMindMap,
|
||||
} from '@/api/review'
|
||||
@@ -83,32 +79,6 @@ describe('review API', () => {
|
||||
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 () => {
|
||||
mockedRequest.get.mockResolvedValue({ code: 200, data: null })
|
||||
mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -23,17 +23,6 @@ export interface ReviewTaskStats {
|
||||
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 {
|
||||
id: number;
|
||||
taskNum: string;
|
||||
@@ -77,36 +66,6 @@ export const getTaskReview = (taskNum: string) => {
|
||||
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) => {
|
||||
return request.get(`/review/mind-map/${taskNum}`);
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -94,7 +94,7 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column prop="reportCount" 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 }">
|
||||
<el-button
|
||||
text
|
||||
@@ -104,6 +104,13 @@ onMounted(() => {
|
||||
>
|
||||
查看记录
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
@click.stop="router.push(`/review/recall/${row.taskNum}`)"
|
||||
>
|
||||
回忆复习
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
createReviewApplication,
|
||||
deleteReviewApplication,
|
||||
getFragmentDetail,
|
||||
getReportDetail,
|
||||
getReviewApplications,
|
||||
getReviewMindMap,
|
||||
getTaskReview,
|
||||
updateReviewApplication,
|
||||
uploadReviewMindMap,
|
||||
type ReviewApplication,
|
||||
type ReviewFeedItem,
|
||||
type ReviewMindMap,
|
||||
} from "@/api/review";
|
||||
@@ -34,21 +29,6 @@ const isEditing = ref(false);
|
||||
const editContent = ref("");
|
||||
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 mindMapUploading = ref(false);
|
||||
|
||||
@@ -68,12 +48,6 @@ const taskSessions = ref<{
|
||||
fragments: ReviewFeedItem[];
|
||||
}[]>([]);
|
||||
|
||||
const statusText: Record<ReviewApplication["status"], string> = {
|
||||
TODO: "待应用",
|
||||
DOING: "进行中",
|
||||
DONE: "已完成",
|
||||
};
|
||||
|
||||
type MindMapNode = {
|
||||
title?: string;
|
||||
notes?: string;
|
||||
@@ -123,16 +97,6 @@ const formatRatio = (ratio?: number): string => {
|
||||
return `${(ratio * 100).toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const resetApplicationForm = () => {
|
||||
editingApplicationId.value = null;
|
||||
applicationForm.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
resourceUrl: "",
|
||||
status: "TODO",
|
||||
};
|
||||
};
|
||||
|
||||
const loadTaskHistory = async (tn: string) => {
|
||||
try {
|
||||
const res = await getTaskReview(tn);
|
||||
@@ -165,9 +129,6 @@ const loadTaskHistory = 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 loadedMindMap: ReviewMindMap | null = mindMapRes?.data || null;
|
||||
mindMap.value = loadedMindMap;
|
||||
@@ -182,7 +143,6 @@ const loadDetail = async () => {
|
||||
taskNum.value = "";
|
||||
sessionInfo.value = null;
|
||||
taskSessions.value = [];
|
||||
applications.value = [];
|
||||
mindMap.value = null;
|
||||
try {
|
||||
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) => {
|
||||
if (!taskNum.value) {
|
||||
ElMessage.warning("未找到关联任务");
|
||||
@@ -402,53 +310,6 @@ watch(
|
||||
</article>
|
||||
|
||||
<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">
|
||||
<div class="extension-head">
|
||||
<h3>思维导图</h3>
|
||||
@@ -467,6 +328,9 @@ watch(
|
||||
>
|
||||
<el-button type="success" :loading="mindMapUploading">上传导图文件</el-button>
|
||||
</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">
|
||||
{{ mindMap.fileFormat }}
|
||||
</el-tag>
|
||||
@@ -605,7 +469,7 @@ watch(
|
||||
|
||||
.review-extension-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -627,48 +491,6 @@ watch(
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -740,12 +562,6 @@ watch(
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 历史记录卡片 */
|
||||
.history-card {
|
||||
padding: 20px 22px;
|
||||
|
||||
@@ -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>
|
||||
@@ -4,6 +4,11 @@ import request from "@/utils/request";
|
||||
import router from "@/router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { getReviewTaskStatsByTask } from "@/api/review";
|
||||
import {
|
||||
getTaskApplications,
|
||||
updateTaskApplication,
|
||||
type TaskApplication,
|
||||
} from "@/api/tasks";
|
||||
|
||||
interface TaskItem {
|
||||
title: string;
|
||||
@@ -27,6 +32,14 @@ interface TaskItem {
|
||||
const tasks = ref<TaskItem[]>([]);
|
||||
const selectedTaskId = ref<number | null>(null);
|
||||
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(() =>
|
||||
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 () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -91,10 +134,12 @@ const fetchTasks = async () => {
|
||||
selectedTaskId.value = nextSelectedTaskId;
|
||||
if (targetTask && shouldLoadStats) {
|
||||
await loadTaskStats(targetTask.taskNum);
|
||||
await loadTaskApplications(targetTask.taskNum);
|
||||
}
|
||||
} catch (error: any) {
|
||||
tasks.value = [];
|
||||
selectedTaskId.value = null;
|
||||
taskApplications.value = [];
|
||||
ElMessage.error(error?.message || "任务列表加载失败,请重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -105,6 +150,9 @@ watch(selectedTaskId, (newId) => {
|
||||
const task = tasks.value.find(t => t.taskId === newId);
|
||||
if (task) {
|
||||
loadTaskStats(task.taskNum);
|
||||
loadTaskApplications(task.taskNum);
|
||||
} else {
|
||||
taskApplications.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,6 +277,39 @@ onMounted(() => {
|
||||
</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>
|
||||
<el-empty v-else description="暂无任务,请先创建任务" />
|
||||
</article>
|
||||
@@ -365,6 +446,51 @@ onMounted(() => {
|
||||
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 {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
@@ -404,5 +530,14 @@ onMounted(() => {
|
||||
.action-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-main {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.application-status {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,13 +3,22 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import request from "@/utils/request";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
createTaskApplication,
|
||||
deleteTaskApplication,
|
||||
getTaskApplications,
|
||||
updateTaskApplication,
|
||||
type TaskApplication,
|
||||
} from "@/api/tasks";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const taskId = route.params.taskId ? Number(route.params.taskId) : null;
|
||||
const buttonName = route.meta.buttonText as string;
|
||||
const isUpdateMode = computed(() => route.meta.action === "update");
|
||||
|
||||
const taskNum = ref("");
|
||||
const taskName = ref("");
|
||||
const taskDescription = ref("");
|
||||
const materialURL = ref("");
|
||||
@@ -22,6 +31,102 @@ const priority = ref({
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
request
|
||||
@@ -46,6 +151,7 @@ const loadTask = () => {
|
||||
request.get(`/tasks/${taskId}`, {}).then((result) => {
|
||||
if (result.code === 200) {
|
||||
const data = result.data;
|
||||
taskNum.value = data.taskNum || "";
|
||||
taskName.value = data.taskName;
|
||||
taskDescription.value = data.taskDescription;
|
||||
materialURL.value = data.materialURL || data.materialUrl || "";
|
||||
@@ -56,6 +162,9 @@ const loadTask = () => {
|
||||
futureValue: data.futureValue,
|
||||
subjectivePriority: data.subjectivePriority,
|
||||
};
|
||||
if (taskNum.value) {
|
||||
loadApplications(taskNum.value);
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(`任务加载失败:${result.message}`);
|
||||
}
|
||||
@@ -169,6 +278,54 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
</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">
|
||||
<el-button type="success" size="large" @click="submitTask">{{ buttonName }}</el-button>
|
||||
<el-button size="large" @click="cancelTask">取消</el-button>
|
||||
@@ -207,6 +364,67 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
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 {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
@@ -226,5 +444,19 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
width: 100%;
|
||||
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>
|
||||
|
||||
@@ -32,6 +32,11 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/components/ReviewDetail.vue"),
|
||||
meta: { requiresAuth: true, title: "复习详情" },
|
||||
},
|
||||
{
|
||||
path: "/review/recall/:taskNum",
|
||||
component: () => import("@/components/ReviewRecall.vue"),
|
||||
meta: { requiresAuth: true, title: "回忆复习" },
|
||||
},
|
||||
{
|
||||
path: "/start-task/:taskNum",
|
||||
name: "start-task",
|
||||
|
||||
Reference in New Issue
Block a user