diff --git a/src/api/projectStudyTime.js b/src/api/projectStudyTime.js new file mode 100644 index 0000000..35bbfca --- /dev/null +++ b/src/api/projectStudyTime.js @@ -0,0 +1,17 @@ +import request from "@/utils/request"; + +export function recordProjectStudyTime(params) { + return request({ + url: "/api/student/project-study-time/record", + method: "post", + params, + headers: { repeatSubmit: false }, + }); +} + +export function getCurrentProjectStudyTime() { + return request({ + url: "/api/student/project-study-time/current", + method: "get", + }); +} diff --git a/src/composables/useProjectStudyTime.js b/src/composables/useProjectStudyTime.js new file mode 100644 index 0000000..c6c0298 --- /dev/null +++ b/src/composables/useProjectStudyTime.js @@ -0,0 +1,81 @@ +import { recordProjectStudyTime } from "@/api/projectStudyTime"; +import { isStudentDemo } from "@/utils/studentDemo"; + +const TRACKING_INTERVAL_MS = 60 * 1000; +const PROJECT_BY_TASK_KEY = { + "new-product-survey": "互联网产品开发基础认知", + "product-development-factors": "互联网产品开发基础认知", + "product-development-process": "互联网产品开发基础认知", + "industry-demand-analysis": "市场洞察与需求分析", + "competitive-environment-analysis": "市场洞察与需求分析", + "market-opportunity-selection": "市场洞察与需求分析", + "business-feasibility": "市场洞察与需求分析", + "target-user-profile": "市场洞察与需求分析", + "consumer-behavior": "市场洞察与需求分析", + "consumer-scenario": "市场洞察与需求分析", + "feature-conversion": "市场洞察与需求分析", + "product-value-positioning": "市场洞察与需求分析", + "product-structure": "产品规划与设计", + "after-sales-service": "产品规划与设计", + "requirements-doc": "产品规划与设计", + "hardware-cost": "产品开发与测试验证", + "supplier-evaluation": "产品开发与测试验证", + "purchase-demand-forecast": "产品开发与测试验证", + "product-pricing": "产品开发与测试验证", + "category-optimization": "产品上线与运营推广", + "release-channel-evaluation": "产品上线与运营推广", + "promotion-effect-optimization": "产品上线与运营推广", + "product-diagnosis-iteration": "产品上线与运营推广", + "comprehensive-case-training": "综合实训", +}; + +export function resolveProjectName(taskKey, configuredProjectName, fallback = "") { + return String(configuredProjectName || "").trim() + || PROJECT_BY_TASK_KEY[String(taskKey || "").trim()] + || String(fallback || "").trim(); +} + +export function useProjectStudyTime(projectName) { + let activeSince = Date.now(); + let trackedProjectName = String(projectName.value || "").trim(); + let timerId; + + const flush = (allowHidden = false) => { + if (isStudentDemo() || (document.hidden && !allowHidden)) return; + const name = trackedProjectName; + const now = Date.now(); + const durationSeconds = Math.floor((now - activeSince) / 1000); + activeSince = now; + if (!name || durationSeconds <= 0) return; + recordProjectStudyTime({ projectName: name, durationSeconds }).catch(() => {}); + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + flush(true); + } else { + activeSince = Date.now(); + } + }; + + watch(projectName, (nextProjectName) => { + flush(true); + trackedProjectName = String(nextProjectName || "").trim(); + activeSince = Date.now(); + }); + + onMounted(() => { + activeSince = Date.now(); + trackedProjectName = String(projectName.value || "").trim(); + document.addEventListener("visibilitychange", handleVisibilityChange); + timerId = window.setInterval(flush, TRACKING_INTERVAL_MS); + }); + + onBeforeUnmount(() => { + flush(true); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.clearInterval(timerId); + }); + + return { flush }; +} diff --git a/src/views/analysis-of-students/analysis.vue b/src/views/analysis-of-students/analysis.vue index 21653e9..00f7552 100644 --- a/src/views/analysis-of-students/analysis.vue +++ b/src/views/analysis-of-students/analysis.vue @@ -27,6 +27,7 @@ import * as scoreApi from "@/api/teacher.js"; import useUserStore from "@/store/modules/user"; import * as indexApi from "@/api/index"; +import { getCurrentProjectStudyTime } from "@/api/projectStudyTime"; const userStore = useUserStore(); const { proxy } = getCurrentInstance(); @@ -63,20 +64,12 @@ const hasScoreData = ref(false); const hasTeacherScoreData = (row) => { return Number(row.completedTaskCount || 0) > 0 || moduleMap.some(({ key }) => row[`${key}Score`] !== null && row[`${key}Score`] !== undefined); }; -const studyTimeFields = [ - { projectName: "市场需求挖掘", field: "marketResearchTime" }, - { projectName: "产品规划", field: "productPlanningTime" }, - { projectName: "产品投放测试", field: "productLaunchTestTime" }, - { projectName: "供应渠道管理", field: "supplyChannelTime" }, - { projectName: "产品评估与考核", field: "productEvaluationTime" }, -]; const toNumber = (value) => Number(value) || 0; -const hasStudentTrainingData = (report, studyTime, visitRows) => +const hasStudentTrainingData = (report, projectStudyTimes) => (report?.modules || []).some((module) => toNumber(module.score) > 0 || toNumber(module.completedTaskCount) > 0) || (report?.tasks || []).some((task) => task.progressStatus && task.progressStatus !== "NOT_STARTED") - || studyTimeFields.some(({ field }) => toNumber(studyTime?.[field]) > 0) - || (visitRows || []).some((item) => toNumber(item.visitCount) > 0); -const buildStudentAnalysisData = (report, studyTime, visitRows) => ({ + || (projectStudyTimes || []).some((item) => toNumber(item.studySeconds) > 0); +const buildStudentAnalysisData = (report, projectStudyTimes) => ({ trainingScores: { totalScore: toNumber(report?.totalScore), rank: report?.rank ?? null, @@ -90,13 +83,10 @@ const buildStudentAnalysisData = (report, studyTime, visitRows) => ({ })), }, learningData: { - studyTimeMinutes: studyTimeFields.map(({ projectName, field }) => ({ - projectName, - minutes: toNumber(studyTime?.[field]), - })), - projectVisits: (visitRows || []).map((item) => ({ - projectName: item.project, - visitCount: toNumber(item.visitCount), + projectStudyTimes: (projectStudyTimes || []).map((item) => ({ + projectName: item.projectName, + studySeconds: toNumber(item.studySeconds), + studyMinutes: Number((toNumber(item.studySeconds) / 60).toFixed(1)), })), taskProgress: (report?.tasks || []).map((task) => ({ taskName: task.taskName, @@ -114,21 +104,19 @@ const getScoreList = async () => { proxy.$cache.session.remove("learningAnalysisData"); try { if (roleId.value == 4) { - const [reportResult, studyTimeResult, visitCountResult] = await Promise.allSettled([ + const [reportResult, projectStudyTimeResult] = await Promise.allSettled([ indexApi.getCurrentExperimentReport(), - indexApi.getStudyTime({ userId: userStore.userInfo.userId }), - indexApi.getUserVisitCount({ userId: userStore.userInfo.userId, schoolId: userStore.userInfo.schoolId }), + getCurrentProjectStudyTime(), ]); if (reportResult.status !== "fulfilled") { throw reportResult.reason; } const report = reportResult.value?.data || {}; - const studyTime = studyTimeResult.status === "fulfilled" ? (studyTimeResult.value?.data || {}) : {}; - const visitRows = visitCountResult.status === "fulfilled" && Array.isArray(visitCountResult.value?.data) - ? visitCountResult.value.data + const projectStudyTimes = projectStudyTimeResult.status === "fulfilled" && Array.isArray(projectStudyTimeResult.value?.data) + ? projectStudyTimeResult.value.data : []; - scoreList.value = buildStudentAnalysisData(report, studyTime, visitRows); - hasScoreData.value = hasStudentTrainingData(report, studyTime, visitRows); + scoreList.value = buildStudentAnalysisData(report, projectStudyTimes); + hasScoreData.value = hasStudentTrainingData(report, projectStudyTimes); } else { const res = await scoreApi.getScoreList({ index: 1, diff --git a/src/views/student/index.vue b/src/views/student/index.vue index 8adfef2..4a2d682 100644 --- a/src/views/student/index.vue +++ b/src/views/student/index.vue @@ -50,7 +50,7 @@ @@ -64,24 +64,12 @@ 累计投入 {{ totalStudyMinutes }} 分钟 -
-
+
+
-
-
-
- - {{ module.moduleName }} - {{ progressText(module) }} -
- -
+
+
@@ -92,25 +80,39 @@ import * as echarts from "echarts"; import useUserStore from "@/store/modules/user"; import * as indexApi from "@/api/index"; +import { getCurrentProjectStudyTime } from "@/api/projectStudyTime"; const userStore = useUserStore(); const scoreChartRef = ref(null); +const studyTimeChartRef = ref(null); const progressChartRef = ref(null); const report = ref({ totalScore: 0, modules: [] }); -const studyTime = ref({}); +const projectStudyTimes = ref([]); const loading = ref(true); const loadError = ref(""); const MODULE_COLORS = ["#6958e7", "#3698ee", "#ffb83e", "#ff6f4a", "#45b982", "#39b8ce"]; let scoreChart; +let studyTimeChart; let progressChart; const displayModules = computed(() => report.value.modules || []); -const totalStudyMinutes = computed(() => - Object.values(studyTime.value || {}).reduce((total, value) => total + (Number(value) || 0), 0) -); +const studySecondsByProject = computed(() => Object.fromEntries( + projectStudyTimes.value.map((item) => [item.projectName, Number(item.studySeconds) || 0]) +)); +const displayStudyProjects = computed(() => { + const knownProjects = displayModules.value.map((module) => module.moduleName); + const extraProjects = projectStudyTimes.value.map((item) => item.projectName).filter((name) => !knownProjects.includes(name)); + return [...knownProjects, ...extraProjects].map((projectName) => ({ + projectName, + studySeconds: studySecondsByProject.value[projectName] || 0, + })); +}); +const totalStudyMinutes = computed(() => Math.round( + displayStudyProjects.value.reduce((total, item) => total + item.studySeconds, 0) / 60 +)); const studyHours = computed(() => (totalStudyMinutes.value / 60).toFixed(1)); @@ -183,22 +185,52 @@ const renderProgressChart = () => { }, true); }; +const renderStudyTimeChart = () => { + if (!studyTimeChartRef.value) return; + studyTimeChart = studyTimeChart || echarts.init(studyTimeChartRef.value); + const projects = displayStudyProjects.value; + studyTimeChart.setOption({ + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + valueFormatter: (value) => `${(Number(value) || 0).toFixed(1)} 分钟`, + }, + grid: { left: 24, right: 24, top: 36, bottom: 22, containLabel: true }, + xAxis: { type: "value", name: "分钟", minInterval: 1 }, + yAxis: { + type: "category", + inverse: true, + axisLabel: { width: 136, overflow: "truncate" }, + data: projects.map((item) => item.projectName), + }, + series: [{ + name: "学习时长", + type: "bar", + barMaxWidth: 18, + itemStyle: { color: "#6958e7", borderRadius: [0, 9, 9, 0] }, + data: projects.map((item) => Number((item.studySeconds / 60).toFixed(1))), + }], + }, true); +}; + const renderCharts = () => { renderScoreChart(); + renderStudyTimeChart(); renderProgressChart(); }; const resizeCharts = () => { scoreChart?.resize(); + studyTimeChart?.resize(); progressChart?.resize(); }; const loadDashboard = async () => { loading.value = true; loadError.value = ""; - const [reportResult, timeResult] = await Promise.allSettled([ + const [reportResult, projectStudyTimeResult] = await Promise.allSettled([ indexApi.getCurrentExperimentReport(), - indexApi.getStudyTime({ userId: userStore.userInfo.userId }) + getCurrentProjectStudyTime(), ]); if (reportResult.status !== "fulfilled") { @@ -208,7 +240,9 @@ const loadDashboard = async () => { } report.value = reportResult.value?.data || { totalScore: 0, modules: [] }; - studyTime.value = timeResult.status === "fulfilled" ? (timeResult.value?.data || {}) : {}; + projectStudyTimes.value = projectStudyTimeResult.status === "fulfilled" && Array.isArray(projectStudyTimeResult.value?.data) + ? projectStudyTimeResult.value.data + : []; loading.value = false; await nextTick(); renderCharts(); @@ -222,6 +256,7 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener("resize", resizeCharts); scoreChart?.dispose(); + studyTimeChart?.dispose(); progressChart?.dispose(); }); @@ -282,7 +317,7 @@ onBeforeUnmount(() => { .score-table { width: 100%; min-width: 760px; } .score-table :deep(.el-table__cell) { white-space: nowrap; } -.progress-layout { display: grid; grid-template-columns: 190px minmax(370px, 1fr) minmax(320px, 0.9fr); gap: 24px; align-items: stretch; } +.progress-layout { display: grid; grid-template-columns: 190px minmax(370px, 1fr) minmax(370px, 1fr); gap: 24px; align-items: stretch; } .learning-time { display: flex; @@ -312,8 +347,8 @@ onBeforeUnmount(() => { .time-value { font-size: 25px; font-weight: 800; line-height: 1; } .time-unit { margin-top: 5px; font-size: 13px; letter-spacing: 0.08em; } -.progress-chart-wrap { min-width: 0; } -.progress-chart { min-height: 270px; width: 100%; } +.study-time-chart-wrap, .progress-chart-wrap { min-width: 0; } +.study-time-chart, .progress-chart { min-height: 270px; width: 100%; } .module-progress-list { display: flex; @@ -334,7 +369,7 @@ onBeforeUnmount(() => { .score-visual { border-right: 0; border-bottom: 1px dashed #d8e2f1; } .score-table-wrap { padding: 20px 0 0; } .progress-layout { grid-template-columns: 180px 1fr; } - .module-progress-list { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .progress-chart-wrap { grid-column: 1 / -1; } } @media (max-width: 760px) { @@ -345,6 +380,6 @@ onBeforeUnmount(() => { .score-summary { left: 18px; top: 18px; } .progress-layout { grid-template-columns: 1fr; } .learning-time { min-height: 210px; } - .module-progress-list { grid-column: auto; grid-template-columns: 1fr; } + .progress-chart-wrap { grid-column: auto; } } diff --git a/src/views/training/CustomTrainingStepPage.vue b/src/views/training/CustomTrainingStepPage.vue index c64490a..ca14a43 100644 --- a/src/views/training/CustomTrainingStepPage.vue +++ b/src/views/training/CustomTrainingStepPage.vue @@ -47,6 +47,7 @@ import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; +import { resolveProjectName, useProjectStudyTime } from "@/composables/useProjectStudyTime"; const route = useRoute(); const router = useRouter(); @@ -60,6 +61,8 @@ const saving = ref(false); const uploading = ref(false); const taskSteps = computed(() => parseSteps(task.value?.steps)); const stepIndex = computed(() => taskSteps.value.findIndex((item) => item.id === step.value?.id)); +const projectName = computed(() => resolveProjectName(route.params.taskKey, task.value?.projectName, task.value?.taskName)); +useProjectStudyTime(projectName); watch( () => [route.params.taskKey, route.params.stepId], diff --git a/src/views/training/GenericTrainingPage.vue b/src/views/training/GenericTrainingPage.vue index 45c51df..ca98819 100644 --- a/src/views/training/GenericTrainingPage.vue +++ b/src/views/training/GenericTrainingPage.vue @@ -410,6 +410,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"; import { isStudentDemo } from "@/utils/studentDemo"; +import { resolveProjectName, useProjectStudyTime } from "@/composables/useProjectStudyTime"; const route = useRoute(); const { proxy } = getCurrentInstance(); @@ -420,6 +421,8 @@ const isProductDevelopmentProcess = computed(() => pageKey.value === "product-de const isComprehensiveCaseTraining = computed(() => pageKey.value === "comprehensive-case-training"); const remoteTask = ref(null); const page = computed(() => mergeTrainingPage(getStudentTrainingPage(pageKey.value), remoteTask.value)); +const projectName = computed(() => resolveProjectName(pageKey.value, remoteTask.value?.projectName, page.value?.title)); +useProjectStudyTime(projectName); const buildInitialForm = () => Object.fromEntries((page.value.fields || []).map((field) => [field.key, ""])); diff --git a/tests/ai-learning-analysis.static.test.cjs b/tests/ai-learning-analysis.static.test.cjs index 5c8bd32..6af4989 100644 --- a/tests/ai-learning-analysis.static.test.cjs +++ b/tests/ai-learning-analysis.static.test.cjs @@ -17,8 +17,8 @@ test("learning analysis request does not send the commerce login token", () => { test("learning analysis page sends current training scores and learning activity data", () => { assert.match(analysisSource, /暂无成绩数据,暂时无法进行AI学情分析/); assert.match(analysisSource, /getCurrentExperimentReport/); - assert.match(analysisSource, /getStudyTime/); - assert.match(analysisSource, /getUserVisitCount/); + assert.match(analysisSource, /getCurrentProjectStudyTime/); + assert.match(analysisSource, /projectStudyTimes/); assert.match(analysisSource, /trainingScores/); assert.match(analysisSource, /learningData/); assert.match(analysisSource, /learningAnalysisData/); diff --git a/tests/project-study-time.static.test.cjs b/tests/project-study-time.static.test.cjs new file mode 100644 index 0000000..af93422 --- /dev/null +++ b/tests/project-study-time.static.test.cjs @@ -0,0 +1,37 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const root = path.resolve(__dirname, ".."); +const api = fs.readFileSync(path.join(root, "src/api/projectStudyTime.js"), "utf8"); +const genericPage = fs.readFileSync(path.join(root, "src/views/training/GenericTrainingPage.vue"), "utf8"); +const customPage = fs.readFileSync(path.join(root, "src/views/training/CustomTrainingStepPage.vue"), "utf8"); +const dashboard = fs.readFileSync(path.join(root, "src/views/student/index.vue"), "utf8"); +const analysis = fs.readFileSync(path.join(root, "src/views/analysis-of-students/analysis.vue"), "utf8"); + +test("project study time API records and loads current student's module durations", () => { + assert.match(api, /recordProjectStudyTime/); + assert.match(api, /getCurrentProjectStudyTime/); + assert.match(api, /\/api\/student\/project-study-time\/record/); + assert.match(api, /\/api\/student\/project-study-time\/current/); +}); + +test("training pages flush time spent in the active project", () => { + for (const page of [genericPage, customPage]) { + assert.match(page, /useProjectStudyTime/); + assert.match(page, /projectName/); + } +}); + +test("student dashboard visualizes per-project study time and keeps task progress beside it", () => { + assert.match(dashboard, /studyTimeChartRef/); + assert.match(dashboard, /renderStudyTimeChart/); + assert.match(dashboard, /getCurrentProjectStudyTime/); + assert.match(dashboard, /progressChartRef/); +}); + +test("AI learning analysis reads current project study durations", () => { + assert.match(analysis, /getCurrentProjectStudyTime/); + assert.match(analysis, /projectStudyTimes/); +});