feat: complete review workflow UI
This commit is contained in:
@@ -20,7 +20,7 @@ test.describe('Auth guard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('allows access to /welcome when authenticated', async ({ page }) => {
|
test('allows access to /welcome when authenticated', async ({ page }) => {
|
||||||
await page.route('**/api/review/feed', (route) =>
|
await page.route('**/api/review/feed**', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
|||||||
+19
-21
@@ -2,31 +2,29 @@ import { test, expect } from '@playwright/test';
|
|||||||
|
|
||||||
const mockTasksData = {
|
const mockTasksData = {
|
||||||
code: 200,
|
code: 200,
|
||||||
data: {
|
data: [
|
||||||
records: [
|
{
|
||||||
{
|
taskNum: 'T001',
|
||||||
taskNum: 'T001',
|
taskName: '学习 Vue3',
|
||||||
taskName: '学习 Vue3',
|
reportCount: 3,
|
||||||
reportCount: 3,
|
fragmentCount: 5,
|
||||||
fragmentCount: 5,
|
effectiveTime: 9000,
|
||||||
effectiveTime: '2h 30m',
|
},
|
||||||
},
|
{
|
||||||
{
|
taskNum: 'T002',
|
||||||
taskNum: 'T002',
|
taskName: '学习 TypeScript',
|
||||||
taskName: '学习 TypeScript',
|
reportCount: 1,
|
||||||
reportCount: 1,
|
fragmentCount: 2,
|
||||||
fragmentCount: 2,
|
effectiveTime: 4500,
|
||||||
effectiveTime: '1h 15m',
|
},
|
||||||
},
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const mockTasksEmpty = { code: 200, data: { records: [] } };
|
const mockTasksEmpty = { code: 200, data: [] };
|
||||||
const mockFeed = { code: 200, data: [] };
|
const mockFeed = { code: 200, data: [] };
|
||||||
|
|
||||||
test.describe('Review page', () => {
|
test.describe('Review page', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.route('**/api/tasks**', (route) =>
|
await page.route('**/api/review/tasks**', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
@@ -52,7 +50,7 @@ test.describe('Review page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows empty state when no tasks', async ({ page }) => {
|
test('shows empty state when no tasks', async ({ page }) => {
|
||||||
await page.route('**/api/tasks**', (route) =>
|
await page.route('**/api/review/tasks**', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
|
|||||||
|
|
||||||
test.describe('Welcome page', () => {
|
test.describe('Welcome page', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.route('**/api/review/feed', (route) =>
|
await page.route('**/api/review/feed**', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
|||||||
@@ -5,12 +5,25 @@ vi.mock('@/utils/request', () => ({
|
|||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
put: vi.fn(),
|
put: vi.fn(),
|
||||||
delete: vi.fn(),
|
del: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import { getReviewFeed, getReportDetail, getFragmentDetail, getTaskReview } from '@/api/review'
|
import {
|
||||||
|
createReviewApplication,
|
||||||
|
deleteReviewApplication,
|
||||||
|
getFragmentDetail,
|
||||||
|
getReportDetail,
|
||||||
|
getReviewApplications,
|
||||||
|
getReviewFeed,
|
||||||
|
getReviewMindMap,
|
||||||
|
getReviewTaskStats,
|
||||||
|
getReviewTaskStatsByTask,
|
||||||
|
getTaskReview,
|
||||||
|
updateReviewApplication,
|
||||||
|
upsertReviewMindMap,
|
||||||
|
} from '@/api/review'
|
||||||
|
|
||||||
const mockedRequest = vi.mocked(request)
|
const mockedRequest = vi.mocked(request)
|
||||||
|
|
||||||
@@ -22,14 +35,32 @@ describe('review API', () => {
|
|||||||
it('getReviewFeed calls GET /review/feed with limit', async () => {
|
it('getReviewFeed calls GET /review/feed with limit', async () => {
|
||||||
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
|
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
|
||||||
const result = await getReviewFeed(10)
|
const result = await getReviewFeed(10)
|
||||||
expect(mockedRequest.get).toHaveBeenCalledWith('/review/feed', { limit: 10 })
|
expect(mockedRequest.get).toHaveBeenCalledWith('/review/feed', { limit: 10, mode: 'recent' })
|
||||||
expect(result).toEqual({ code: 200, data: [] })
|
expect(result).toEqual({ code: 200, data: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('getReviewFeed uses default limit of 30', async () => {
|
it('getReviewFeed uses default limit of 30 and recent mode', async () => {
|
||||||
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
|
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
|
||||||
await getReviewFeed()
|
await getReviewFeed()
|
||||||
expect(mockedRequest.get).toHaveBeenCalledWith('/review/feed', { limit: 30 })
|
expect(mockedRequest.get).toHaveBeenCalledWith('/review/feed', { limit: 30, mode: 'recent' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getReviewFeed accepts random mode', async () => {
|
||||||
|
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
|
||||||
|
await getReviewFeed(50, 'random')
|
||||||
|
expect(mockedRequest.get).toHaveBeenCalledWith('/review/feed', { limit: 50, mode: 'random' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getReviewTaskStats calls GET /review/tasks', async () => {
|
||||||
|
mockedRequest.get.mockResolvedValue({ code: 200, data: [] })
|
||||||
|
await getReviewTaskStats()
|
||||||
|
expect(mockedRequest.get).toHaveBeenCalledWith('/review/tasks')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getReviewTaskStatsByTask calls GET /review/tasks/:taskNum', async () => {
|
||||||
|
mockedRequest.get.mockResolvedValue({ code: 200, data: {} })
|
||||||
|
await getReviewTaskStatsByTask('T001')
|
||||||
|
expect(mockedRequest.get).toHaveBeenCalledWith('/review/tasks/T001')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('getReportDetail calls GET /review/report/:id', async () => {
|
it('getReportDetail calls GET /review/report/:id', async () => {
|
||||||
@@ -50,4 +81,49 @@ describe('review API', () => {
|
|||||||
await getTaskReview('T001')
|
await getTaskReview('T001')
|
||||||
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 () => {
|
||||||
|
mockedRequest.get.mockResolvedValue({ code: 200, data: null })
|
||||||
|
mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } })
|
||||||
|
|
||||||
|
await getReviewMindMap('T001')
|
||||||
|
expect(mockedRequest.get).toHaveBeenCalledWith('/review/mind-map/T001')
|
||||||
|
|
||||||
|
await upsertReviewMindMap('T001', {
|
||||||
|
title: 'Map',
|
||||||
|
content: 'content',
|
||||||
|
contentFormat: 'TEXT',
|
||||||
|
})
|
||||||
|
expect(mockedRequest.put).toHaveBeenCalledWith('/review/mind-map/T001', {
|
||||||
|
title: 'Map',
|
||||||
|
content: 'content',
|
||||||
|
contentFormat: 'TEXT',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+89
-2
@@ -10,8 +10,50 @@ export interface ReviewFeedItem {
|
|||||||
createdTime: string;
|
createdTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getReviewFeed = (limit: number = 30) => {
|
export interface ReviewTaskStats {
|
||||||
return request.get("/review/feed", { limit });
|
taskNum: string;
|
||||||
|
taskName: string;
|
||||||
|
reportCount: number;
|
||||||
|
fragmentCount: number;
|
||||||
|
effectiveTime: number;
|
||||||
|
sessionCount: number;
|
||||||
|
todayEffectiveTime: number;
|
||||||
|
weekEffectiveTime: number;
|
||||||
|
avgEffectiveTime: 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 {
|
||||||
|
id: number;
|
||||||
|
taskNum: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
contentFormat: "TEXT" | "MERMAID" | "JSON";
|
||||||
|
createdTime?: string;
|
||||||
|
lastModifiedTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getReviewFeed = (limit: number = 30, mode: "recent" | "random" = "recent") => {
|
||||||
|
return request.get("/review/feed", { limit, mode });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getReviewTaskStats = () => {
|
||||||
|
return request.get("/review/tasks");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getReviewTaskStatsByTask = (taskNum: string) => {
|
||||||
|
return request.get(`/review/tasks/${taskNum}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getReportDetail = (id: number) => {
|
export const getReportDetail = (id: number) => {
|
||||||
@@ -25,3 +67,48 @@ export const getFragmentDetail = (id: number) => {
|
|||||||
export const getTaskReview = (taskNum: string) => {
|
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) => {
|
||||||
|
return request.get(`/review/mind-map/${taskNum}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertReviewMindMap = (
|
||||||
|
taskNum: string,
|
||||||
|
payload: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
contentFormat?: ReviewMindMap["contentFormat"];
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return request.put(`/review/mind-map/${taskNum}`, payload);
|
||||||
|
};
|
||||||
|
|||||||
+47
-18
@@ -1,17 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import request from "@/utils/request";
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import {
|
||||||
|
getReviewTaskStats,
|
||||||
|
getTaskReview,
|
||||||
|
type ReviewFeedItem,
|
||||||
|
type ReviewTaskStats,
|
||||||
|
} from "@/api/review";
|
||||||
|
|
||||||
interface ReviewTask {
|
const router = useRouter();
|
||||||
taskNum: string;
|
|
||||||
taskName: string;
|
|
||||||
reportCount: number | string;
|
|
||||||
fragmentCount: number | string;
|
|
||||||
effectiveTime: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const tasks = ref<ReviewTask[]>([]);
|
const openingTaskNum = ref("");
|
||||||
|
const tasks = ref<ReviewTaskStats[]>([]);
|
||||||
|
|
||||||
const summary = computed(() => ({
|
const summary = computed(() => ({
|
||||||
totalTasks: tasks.value.length,
|
totalTasks: tasks.value.length,
|
||||||
@@ -31,19 +33,30 @@ const formatEffectiveTime = (seconds: number | string): string => {
|
|||||||
const loadTasks = async () => {
|
const loadTasks = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await request.get("/review/tasks");
|
const res = await getReviewTaskStats();
|
||||||
tasks.value = (res?.data || []).map((item: any) => ({
|
tasks.value = res?.data || [];
|
||||||
taskNum: item.taskNum,
|
|
||||||
taskName: item.taskName,
|
|
||||||
reportCount: item.reportCount ?? 0,
|
|
||||||
fragmentCount: item.fragmentCount ?? 0,
|
|
||||||
effectiveTime: item.effectiveTime ?? 0,
|
|
||||||
}));
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openTaskReview = async (row: ReviewTaskStats) => {
|
||||||
|
if (!row.taskNum) return;
|
||||||
|
openingTaskNum.value = row.taskNum;
|
||||||
|
try {
|
||||||
|
const res = await getTaskReview(row.taskNum);
|
||||||
|
const items: ReviewFeedItem[] = res?.data || [];
|
||||||
|
const first = items[0];
|
||||||
|
if (!first) {
|
||||||
|
ElMessage.info("该任务暂无可复习内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/review/detail/${first.sourceType.toLowerCase()}/${first.id}`);
|
||||||
|
} finally {
|
||||||
|
openingTaskNum.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTasks();
|
loadTasks();
|
||||||
});
|
});
|
||||||
@@ -72,7 +85,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article class="surface-card table-card">
|
<article class="surface-card table-card">
|
||||||
<el-table v-loading="loading" :data="tasks" stripe>
|
<el-table v-loading="loading" :data="tasks" stripe @row-click="openTaskReview">
|
||||||
<el-table-column prop="taskName" label="任务名" min-width="180" />
|
<el-table-column prop="taskName" label="任务名" min-width="180" />
|
||||||
<el-table-column prop="effectiveTime" label="累计有效学习时长" min-width="150">
|
<el-table-column prop="effectiveTime" label="累计有效学习时长" min-width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -81,6 +94,18 @@ 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">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
type="success"
|
||||||
|
:loading="openingTaskNum === row.taskNum"
|
||||||
|
@click.stop="openTaskReview(row)"
|
||||||
|
>
|
||||||
|
查看记录
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -125,6 +150,10 @@ onMounted(() => {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-card :deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
+387
-44
@@ -1,7 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
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 { getSessionDetail } from "@/api/studySessions";
|
||||||
import { updateFragments } from "@/api/reportFragments";
|
import { updateFragments } from "@/api/reportFragments";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
@@ -17,12 +30,37 @@ const content = ref("");
|
|||||||
const sourceTypeLabel = ref("");
|
const sourceTypeLabel = ref("");
|
||||||
const taskNum = ref("");
|
const taskNum = ref("");
|
||||||
|
|
||||||
// 编辑状态
|
|
||||||
const isEditing = ref(false);
|
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 mindMapSaving = ref(false);
|
||||||
|
const mindMapForm = ref<{
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
contentFormat: ReviewMindMap["contentFormat"];
|
||||||
|
}>({
|
||||||
|
title: "任务思维导图",
|
||||||
|
content: "",
|
||||||
|
contentFormat: "TEXT",
|
||||||
|
});
|
||||||
|
|
||||||
const sessionInfo = ref<{
|
const sessionInfo = ref<{
|
||||||
taskName: string;
|
taskName: string;
|
||||||
sessionNum: string;
|
sessionNum: string;
|
||||||
@@ -32,7 +70,6 @@ const sessionInfo = ref<{
|
|||||||
startTime?: string;
|
startTime?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 该任务下的所有学习记录(按 session 分组)
|
|
||||||
const taskSessions = ref<{
|
const taskSessions = ref<{
|
||||||
sessionNum: string;
|
sessionNum: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@@ -40,7 +77,13 @@ const taskSessions = ref<{
|
|||||||
fragments: ReviewFeedItem[];
|
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 "-";
|
if (seconds == null) return "-";
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
@@ -50,47 +93,26 @@ const formatDuration = (seconds: number): string => {
|
|||||||
return `${s}秒`;
|
return `${s}秒`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadDetail = async () => {
|
const formatRatio = (ratio?: number): string => {
|
||||||
loading.value = true;
|
if (ratio == null) return "-";
|
||||||
try {
|
return `${(ratio * 100).toFixed(1)}%`;
|
||||||
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 loadTaskHistory = async (tn: string, currentSessionNum: string) => {
|
const resetApplicationForm = () => {
|
||||||
|
editingApplicationId.value = null;
|
||||||
|
applicationForm.value = {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
resourceUrl: "",
|
||||||
|
status: "TODO",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTaskHistory = async (tn: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await getTaskReview(tn);
|
const res = await getTaskReview(tn);
|
||||||
const items: ReviewFeedItem[] = res?.data || [];
|
const items: ReviewFeedItem[] = res?.data || [];
|
||||||
|
|
||||||
// 按 sessionNum 分组
|
|
||||||
const map = new Map<string, { report: ReviewFeedItem | null; fragments: ReviewFeedItem[]; startTime: string }>();
|
const map = new Map<string, { report: ReviewFeedItem | null; fragments: ReviewFeedItem[]; startTime: string }>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const entry = map.get(item.sessionNum) || { report: null, fragments: [], startTime: item.createdTime };
|
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) => {
|
const goToDetail = (type: string, id: number) => {
|
||||||
router.push(`/review/detail/${type}/${id}`);
|
router.push(`/review/detail/${type}/${id}`);
|
||||||
};
|
};
|
||||||
@@ -146,6 +232,9 @@ const saveEdit = async () => {
|
|||||||
if (res?.code === 200) {
|
if (res?.code === 200) {
|
||||||
content.value = editContent.value;
|
content.value = editContent.value;
|
||||||
isEditing.value = false;
|
isEditing.value = false;
|
||||||
|
if (taskNum.value) {
|
||||||
|
await loadTaskHistory(taskNum.value);
|
||||||
|
}
|
||||||
ElMessage.success("保存成功");
|
ElMessage.success("保存成功");
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.message || "保存失败");
|
ElMessage.error(res?.message || "保存失败");
|
||||||
@@ -157,9 +246,84 @@ const saveEdit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const saveApplication = async () => {
|
||||||
loadDetail();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -189,6 +353,11 @@ onMounted(() => {
|
|||||||
编辑
|
编辑
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</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">
|
<div v-if="isEditing" class="edit-area">
|
||||||
@@ -214,6 +383,82 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</article>
|
</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">
|
<article class="surface-card history-card" v-if="taskSessions.length > 0">
|
||||||
<h3>该任务下的所有学习记录</h3>
|
<h3>该任务下的所有学习记录</h3>
|
||||||
@@ -280,6 +525,15 @@ onMounted(() => {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.meta-task-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--green-800);
|
color: var(--green-800);
|
||||||
@@ -310,6 +564,84 @@ onMounted(() => {
|
|||||||
padding: 40px 0;
|
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 {
|
.history-card {
|
||||||
padding: 20px 22px;
|
padding: 20px 22px;
|
||||||
@@ -420,4 +752,15 @@ onMounted(() => {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.review-extension-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
|
|||||||
import request from "@/utils/request";
|
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";
|
||||||
|
|
||||||
interface TaskItem {
|
interface TaskItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -44,7 +45,7 @@ async function loadTaskStats(taskNum: string) {
|
|||||||
if (!task || task.statsLoaded) return;
|
if (!task || task.statsLoaded) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await request.get(`/review/tasks/${taskNum}`);
|
const res = await getReviewTaskStatsByTask(taskNum);
|
||||||
if (res?.data) {
|
if (res?.data) {
|
||||||
task.reportCount = res.data.reportCount ?? 0;
|
task.reportCount = res.data.reportCount ?? 0;
|
||||||
task.fragmentCount = res.data.fragmentCount ?? 0;
|
task.fragmentCount = res.data.fragmentCount ?? 0;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const duplicatedGroups = computed(() => [...sessionGroups.value, ...sessionGroup
|
|||||||
|
|
||||||
const loadReviewFeed = async () => {
|
const loadReviewFeed = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getReviewFeed(100);
|
const res = await getReviewFeed(100, "random");
|
||||||
reviewItems.value = res?.data || [];
|
reviewItems.value = res?.data || [];
|
||||||
} catch {
|
} catch {
|
||||||
// feed 加载失败不影响页面主体功能
|
// feed 加载失败不影响页面主体功能
|
||||||
|
|||||||
Reference in New Issue
Block a user