diff --git a/e2e/auth-guard.spec.ts b/e2e/auth-guard.spec.ts index 5a559df..a1c6f67 100644 --- a/e2e/auth-guard.spec.ts +++ b/e2e/auth-guard.spec.ts @@ -20,7 +20,7 @@ test.describe('Auth guard', () => { }); 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({ status: 200, contentType: 'application/json', diff --git a/e2e/review.spec.ts b/e2e/review.spec.ts index bf46c5c..a2ae0e6 100644 --- a/e2e/review.spec.ts +++ b/e2e/review.spec.ts @@ -2,31 +2,29 @@ import { test, expect } from '@playwright/test'; const mockTasksData = { code: 200, - data: { - records: [ - { - taskNum: 'T001', - taskName: '学习 Vue3', - reportCount: 3, - fragmentCount: 5, - effectiveTime: '2h 30m', - }, - { - taskNum: 'T002', - taskName: '学习 TypeScript', - reportCount: 1, - fragmentCount: 2, - effectiveTime: '1h 15m', - }, - ], - }, + data: [ + { + taskNum: 'T001', + taskName: '学习 Vue3', + reportCount: 3, + fragmentCount: 5, + effectiveTime: 9000, + }, + { + taskNum: 'T002', + taskName: '学习 TypeScript', + reportCount: 1, + fragmentCount: 2, + effectiveTime: 4500, + }, + ], }; -const mockTasksEmpty = { code: 200, data: { records: [] } }; +const mockTasksEmpty = { code: 200, data: [] }; const mockFeed = { code: 200, data: [] }; test.describe('Review page', () => { test.beforeEach(async ({ page }) => { - await page.route('**/api/tasks**', (route) => + await page.route('**/api/review/tasks**', (route) => route.fulfill({ status: 200, contentType: 'application/json', @@ -52,7 +50,7 @@ test.describe('Review 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({ status: 200, contentType: 'application/json', diff --git a/e2e/welcome.spec.ts b/e2e/welcome.spec.ts index abb93e5..00de01a 100644 --- a/e2e/welcome.spec.ts +++ b/e2e/welcome.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; test.describe('Welcome page', () => { test.beforeEach(async ({ page }) => { - await page.route('**/api/review/feed', (route) => + await page.route('**/api/review/feed**', (route) => route.fulfill({ status: 200, contentType: 'application/json', diff --git a/src/__tests__/api/review.spec.ts b/src/__tests__/api/review.spec.ts index 516f6a2..7e7c661 100644 --- a/src/__tests__/api/review.spec.ts +++ b/src/__tests__/api/review.spec.ts @@ -5,12 +5,25 @@ vi.mock('@/utils/request', () => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), - delete: vi.fn(), + del: vi.fn(), }, })) 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) @@ -22,14 +35,32 @@ describe('review API', () => { it('getReviewFeed calls GET /review/feed with limit', async () => { mockedRequest.get.mockResolvedValue({ code: 200, data: [] }) 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: [] }) }) - 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: [] }) 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 () => { @@ -50,4 +81,49 @@ describe('review API', () => { await getTaskReview('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', + }) + }) }) diff --git a/src/api/review.ts b/src/api/review.ts index c439965..923f162 100644 --- a/src/api/review.ts +++ b/src/api/review.ts @@ -10,8 +10,50 @@ export interface ReviewFeedItem { createdTime: string; } -export const getReviewFeed = (limit: number = 30) => { - return request.get("/review/feed", { limit }); +export interface ReviewTaskStats { + 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) => { @@ -25,3 +67,48 @@ export const getFragmentDetail = (id: number) => { 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}`); +}; + +export const upsertReviewMindMap = ( + taskNum: string, + payload: { + title: string; + content: string; + contentFormat?: ReviewMindMap["contentFormat"]; + }, +) => { + return request.put(`/review/mind-map/${taskNum}`, payload); +}; diff --git a/src/components/Review.vue b/src/components/Review.vue index a495feb..162e927 100644 --- a/src/components/Review.vue +++ b/src/components/Review.vue @@ -1,17 +1,19 @@ +
+
+
+

应用场景

+ {{ applications.length }} +
+ +
+
+
+ {{ item.title }} + {{ statusText[item.status] || item.status }} +
+

{{ item.description }}

+ + {{ item.resourceUrl }} + +
+ 编辑 + 删除 +
+
+
+ + +
+ + + +
+ + + + + + + {{ editingApplicationId ? "更新" : "添加" }} + + 取消 +
+
+
+ +
+
+

思维导图

+ {{ mindMap?.contentFormat || mindMapForm.contentFormat }} +
+ +
+
+ + + + + + +
+ +
+ 保存 +
+
+
+
+

该任务下的所有学习记录

@@ -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; } - \ No newline at end of file +@media (max-width: 900px) { + .review-extension-grid { + grid-template-columns: 1fr; + } + + .form-row { + flex-direction: column; + align-items: stretch; + } +} + + diff --git a/src/components/Study.vue b/src/components/Study.vue index 1f67765..c83a656 100644 --- a/src/components/Study.vue +++ b/src/components/Study.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue"; import request from "@/utils/request"; import router from "@/router"; import { ElMessage } from "element-plus"; +import { getReviewTaskStatsByTask } from "@/api/review"; interface TaskItem { title: string; @@ -44,7 +45,7 @@ async function loadTaskStats(taskNum: string) { if (!task || task.statsLoaded) return; try { - const res = await request.get(`/review/tasks/${taskNum}`); + const res = await getReviewTaskStatsByTask(taskNum); if (res?.data) { task.reportCount = res.data.reportCount ?? 0; task.fragmentCount = res.data.fragmentCount ?? 0; diff --git a/src/components/Welcome.vue b/src/components/Welcome.vue index bfb4cd5..391351d 100644 --- a/src/components/Welcome.vue +++ b/src/components/Welcome.vue @@ -63,7 +63,7 @@ const duplicatedGroups = computed(() => [...sessionGroups.value, ...sessionGroup const loadReviewFeed = async () => { try { - const res = await getReviewFeed(100); + const res = await getReviewFeed(100, "random"); reviewItems.value = res?.data || []; } catch { // feed 加载失败不影响页面主体功能