feat: complete review workflow UI
This commit is contained in:
+388
-45
@@ -1,7 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getReportDetail, getFragmentDetail, getTaskReview, type ReviewFeedItem } from "@/api/review";
|
||||
import {
|
||||
createReviewApplication,
|
||||
deleteReviewApplication,
|
||||
getFragmentDetail,
|
||||
getReportDetail,
|
||||
getReviewApplications,
|
||||
getReviewMindMap,
|
||||
getTaskReview,
|
||||
updateReviewApplication,
|
||||
upsertReviewMindMap,
|
||||
type ReviewApplication,
|
||||
type ReviewFeedItem,
|
||||
type ReviewMindMap,
|
||||
} from "@/api/review";
|
||||
import { getSessionDetail } from "@/api/studySessions";
|
||||
import { updateFragments } from "@/api/reportFragments";
|
||||
import { ElMessage } from "element-plus";
|
||||
@@ -17,12 +30,37 @@ const content = ref("");
|
||||
const sourceTypeLabel = ref("");
|
||||
const taskNum = ref("");
|
||||
|
||||
// 编辑状态
|
||||
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 mindMapSaving = ref(false);
|
||||
const mindMapForm = ref<{
|
||||
title: string;
|
||||
content: string;
|
||||
contentFormat: ReviewMindMap["contentFormat"];
|
||||
}>({
|
||||
title: "任务思维导图",
|
||||
content: "",
|
||||
contentFormat: "TEXT",
|
||||
});
|
||||
|
||||
const sessionInfo = ref<{
|
||||
taskName: string;
|
||||
sessionNum: string;
|
||||
@@ -32,7 +70,6 @@ const sessionInfo = ref<{
|
||||
startTime?: string;
|
||||
} | null>(null);
|
||||
|
||||
// 该任务下的所有学习记录(按 session 分组)
|
||||
const taskSessions = ref<{
|
||||
sessionNum: string;
|
||||
startTime: string;
|
||||
@@ -40,7 +77,13 @@ const taskSessions = ref<{
|
||||
fragments: ReviewFeedItem[];
|
||||
}[]>([]);
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const statusText: Record<ReviewApplication["status"], string> = {
|
||||
TODO: "待应用",
|
||||
DOING: "进行中",
|
||||
DONE: "已完成",
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (seconds == null) return "-";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
@@ -50,47 +93,26 @@ const formatDuration = (seconds: number): string => {
|
||||
return `${s}秒`;
|
||||
};
|
||||
|
||||
const loadDetail = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
let res;
|
||||
if (contentType.value === "report") {
|
||||
res = await getReportDetail(contentId.value);
|
||||
sourceTypeLabel.value = "学习报告";
|
||||
} else {
|
||||
res = await getFragmentDetail(contentId.value);
|
||||
sourceTypeLabel.value = "学习残片";
|
||||
}
|
||||
const data = res?.data;
|
||||
content.value = data?.content || "";
|
||||
|
||||
const sessionNum = data?.sessionNum;
|
||||
if (sessionNum) {
|
||||
try {
|
||||
const sessionRes = await getSessionDetail(sessionNum);
|
||||
const info = sessionRes?.data;
|
||||
sessionInfo.value = info || null;
|
||||
taskNum.value = info?.taskNum || "";
|
||||
|
||||
// 加载该任务下的所有报告和残片
|
||||
if (info?.taskNum) {
|
||||
await loadTaskHistory(info.taskNum, sessionNum);
|
||||
}
|
||||
} catch {
|
||||
// 非关键数据
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
const formatRatio = (ratio?: number): string => {
|
||||
if (ratio == null) return "-";
|
||||
return `${(ratio * 100).toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const loadTaskHistory = async (tn: string, currentSessionNum: string) => {
|
||||
const resetApplicationForm = () => {
|
||||
editingApplicationId.value = null;
|
||||
applicationForm.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
resourceUrl: "",
|
||||
status: "TODO",
|
||||
};
|
||||
};
|
||||
|
||||
const loadTaskHistory = async (tn: string) => {
|
||||
try {
|
||||
const res = await getTaskReview(tn);
|
||||
const items: ReviewFeedItem[] = res?.data || [];
|
||||
|
||||
// 按 sessionNum 分组
|
||||
const map = new Map<string, { report: ReviewFeedItem | null; fragments: ReviewFeedItem[]; startTime: string }>();
|
||||
for (const item of items) {
|
||||
const entry = map.get(item.sessionNum) || { report: null, fragments: [], startTime: item.createdTime };
|
||||
@@ -117,6 +139,70 @@ const loadTaskHistory = async (tn: string, currentSessionNum: 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;
|
||||
mindMapForm.value = loadedMindMap
|
||||
? {
|
||||
title: loadedMindMap.title || "任务思维导图",
|
||||
content: loadedMindMap.content || "",
|
||||
contentFormat: loadedMindMap.contentFormat || "TEXT",
|
||||
}
|
||||
: {
|
||||
title: `${sessionInfo.value?.taskName || "任务"}思维导图`,
|
||||
content: "",
|
||||
contentFormat: "TEXT",
|
||||
};
|
||||
};
|
||||
|
||||
const loadDetail = async () => {
|
||||
loading.value = true;
|
||||
isEditing.value = false;
|
||||
editContent.value = "";
|
||||
content.value = "";
|
||||
sourceTypeLabel.value = "";
|
||||
taskNum.value = "";
|
||||
sessionInfo.value = null;
|
||||
taskSessions.value = [];
|
||||
applications.value = [];
|
||||
mindMap.value = null;
|
||||
try {
|
||||
let res;
|
||||
if (contentType.value === "report") {
|
||||
res = await getReportDetail(contentId.value);
|
||||
sourceTypeLabel.value = "学习报告";
|
||||
} else {
|
||||
res = await getFragmentDetail(contentId.value);
|
||||
sourceTypeLabel.value = "学习残片";
|
||||
}
|
||||
const data = res?.data;
|
||||
content.value = data?.content || "";
|
||||
|
||||
const sessionNum = data?.sessionNum;
|
||||
if (sessionNum) {
|
||||
try {
|
||||
const sessionRes = await getSessionDetail(sessionNum);
|
||||
const info = sessionRes?.data;
|
||||
sessionInfo.value = info || null;
|
||||
taskNum.value = info?.taskNum || "";
|
||||
|
||||
if (info?.taskNum) {
|
||||
await loadTaskHistory(info.taskNum);
|
||||
await loadReviewExtensions(info.taskNum);
|
||||
}
|
||||
} catch {
|
||||
// 非关键数据
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToDetail = (type: string, id: number) => {
|
||||
router.push(`/review/detail/${type}/${id}`);
|
||||
};
|
||||
@@ -146,6 +232,9 @@ const saveEdit = async () => {
|
||||
if (res?.code === 200) {
|
||||
content.value = editContent.value;
|
||||
isEditing.value = false;
|
||||
if (taskNum.value) {
|
||||
await loadTaskHistory(taskNum.value);
|
||||
}
|
||||
ElMessage.success("保存成功");
|
||||
} else {
|
||||
ElMessage.error(res?.message || "保存失败");
|
||||
@@ -157,9 +246,84 @@ const saveEdit = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail();
|
||||
});
|
||||
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 saveMindMap = async () => {
|
||||
if (!taskNum.value) {
|
||||
ElMessage.warning("未找到关联任务");
|
||||
return;
|
||||
}
|
||||
if (!mindMapForm.value.title.trim() || !mindMapForm.value.content.trim()) {
|
||||
ElMessage.warning("思维导图标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
mindMapSaving.value = true;
|
||||
try {
|
||||
const res = await upsertReviewMindMap(taskNum.value, mindMapForm.value);
|
||||
mindMap.value = res?.data || null;
|
||||
ElMessage.success("思维导图已保存");
|
||||
} finally {
|
||||
mindMapSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [contentType.value, contentId.value],
|
||||
() => {
|
||||
loadDetail();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -189,6 +353,11 @@ onMounted(() => {
|
||||
编辑
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="sessionInfo" class="session-stats">
|
||||
<span>实际:{{ formatDuration(sessionInfo.actualTime) }}</span>
|
||||
<span>有效:{{ formatDuration(sessionInfo.effectiveTime) }}</span>
|
||||
<span>效率:{{ formatRatio(sessionInfo.effectivenessRatio) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<div v-if="isEditing" class="edit-area">
|
||||
@@ -214,6 +383,82 @@ onMounted(() => {
|
||||
</template>
|
||||
</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>
|
||||
<el-tag size="small" type="warning" effect="plain">{{ mindMap?.contentFormat || mindMapForm.contentFormat }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="mind-map-form">
|
||||
<div class="form-row">
|
||||
<el-input v-model="mindMapForm.title" placeholder="标题" />
|
||||
<el-select v-model="mindMapForm.contentFormat" placeholder="格式">
|
||||
<el-option label="文本" value="TEXT" />
|
||||
<el-option label="Mermaid" value="MERMAID" />
|
||||
<el-option label="JSON" value="JSON" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="mindMapForm.content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="思维导图内容"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<el-button type="success" :loading="mindMapSaving" @click="saveMindMap">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- 该任务下的所有学习记录 -->
|
||||
<article class="surface-card history-card" v-if="taskSessions.length > 0">
|
||||
<h3>该任务下的所有学习记录</h3>
|
||||
@@ -280,6 +525,15 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
margin: -6px 0 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-task-name {
|
||||
font-weight: 600;
|
||||
color: var(--green-800);
|
||||
@@ -310,6 +564,84 @@ onMounted(() => {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.review-extension-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.extension-card {
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.extension-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.extension-head h3 {
|
||||
margin: 0;
|
||||
color: var(--green-900);
|
||||
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;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 历史记录卡片 */
|
||||
.history-card {
|
||||
padding: 20px 22px;
|
||||
@@ -420,4 +752,15 @@ onMounted(() => {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@media (max-width: 900px) {
|
||||
.review-extension-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user