feat: track and visualize project study time

bugfix/ai-assessment-evidence-rescore
陈沅 1 month ago
parent 95d4bf87ff
commit ad9adf71b2

@ -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",
});
}

@ -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 };
}

@ -27,6 +27,7 @@
import * as scoreApi from "@/api/teacher.js"; import * as scoreApi from "@/api/teacher.js";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
import * as indexApi from "@/api/index"; import * as indexApi from "@/api/index";
import { getCurrentProjectStudyTime } from "@/api/projectStudyTime";
const userStore = useUserStore(); const userStore = useUserStore();
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
@ -63,20 +64,12 @@ const hasScoreData = ref(false);
const hasTeacherScoreData = (row) => { const hasTeacherScoreData = (row) => {
return Number(row.completedTaskCount || 0) > 0 || moduleMap.some(({ key }) => row[`${key}Score`] !== null && row[`${key}Score`] !== undefined); 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 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?.modules || []).some((module) => toNumber(module.score) > 0 || toNumber(module.completedTaskCount) > 0)
|| (report?.tasks || []).some((task) => task.progressStatus && task.progressStatus !== "NOT_STARTED") || (report?.tasks || []).some((task) => task.progressStatus && task.progressStatus !== "NOT_STARTED")
|| studyTimeFields.some(({ field }) => toNumber(studyTime?.[field]) > 0) || (projectStudyTimes || []).some((item) => toNumber(item.studySeconds) > 0);
|| (visitRows || []).some((item) => toNumber(item.visitCount) > 0); const buildStudentAnalysisData = (report, projectStudyTimes) => ({
const buildStudentAnalysisData = (report, studyTime, visitRows) => ({
trainingScores: { trainingScores: {
totalScore: toNumber(report?.totalScore), totalScore: toNumber(report?.totalScore),
rank: report?.rank ?? null, rank: report?.rank ?? null,
@ -90,13 +83,10 @@ const buildStudentAnalysisData = (report, studyTime, visitRows) => ({
})), })),
}, },
learningData: { learningData: {
studyTimeMinutes: studyTimeFields.map(({ projectName, field }) => ({ projectStudyTimes: (projectStudyTimes || []).map((item) => ({
projectName, projectName: item.projectName,
minutes: toNumber(studyTime?.[field]), studySeconds: toNumber(item.studySeconds),
})), studyMinutes: Number((toNumber(item.studySeconds) / 60).toFixed(1)),
projectVisits: (visitRows || []).map((item) => ({
projectName: item.project,
visitCount: toNumber(item.visitCount),
})), })),
taskProgress: (report?.tasks || []).map((task) => ({ taskProgress: (report?.tasks || []).map((task) => ({
taskName: task.taskName, taskName: task.taskName,
@ -114,21 +104,19 @@ const getScoreList = async () => {
proxy.$cache.session.remove("learningAnalysisData"); proxy.$cache.session.remove("learningAnalysisData");
try { try {
if (roleId.value == 4) { if (roleId.value == 4) {
const [reportResult, studyTimeResult, visitCountResult] = await Promise.allSettled([ const [reportResult, projectStudyTimeResult] = await Promise.allSettled([
indexApi.getCurrentExperimentReport(), indexApi.getCurrentExperimentReport(),
indexApi.getStudyTime({ userId: userStore.userInfo.userId }), getCurrentProjectStudyTime(),
indexApi.getUserVisitCount({ userId: userStore.userInfo.userId, schoolId: userStore.userInfo.schoolId }),
]); ]);
if (reportResult.status !== "fulfilled") { if (reportResult.status !== "fulfilled") {
throw reportResult.reason; throw reportResult.reason;
} }
const report = reportResult.value?.data || {}; const report = reportResult.value?.data || {};
const studyTime = studyTimeResult.status === "fulfilled" ? (studyTimeResult.value?.data || {}) : {}; const projectStudyTimes = projectStudyTimeResult.status === "fulfilled" && Array.isArray(projectStudyTimeResult.value?.data)
const visitRows = visitCountResult.status === "fulfilled" && Array.isArray(visitCountResult.value?.data) ? projectStudyTimeResult.value.data
? visitCountResult.value.data
: []; : [];
scoreList.value = buildStudentAnalysisData(report, studyTime, visitRows); scoreList.value = buildStudentAnalysisData(report, projectStudyTimes);
hasScoreData.value = hasStudentTrainingData(report, studyTime, visitRows); hasScoreData.value = hasStudentTrainingData(report, projectStudyTimes);
} else { } else {
const res = await scoreApi.getScoreList({ const res = await scoreApi.getScoreList({
index: 1, index: 1,

@ -50,7 +50,7 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>实训进度概览</span> <span>实训进度概览</span>
<small>完成任务越多,模块进度越靠前</small> <small>项目学习时长与任务完成进度对比</small>
</div> </div>
</template> </template>
@ -64,24 +64,12 @@
<span>累计投入 {{ totalStudyMinutes }} 分钟</span> <span>累计投入 {{ totalStudyMinutes }} 分钟</span>
</section> </section>
<section class="progress-chart-wrap" aria-label="模块任务完成情况"> <section class="study-time-chart-wrap" aria-label="项目学习时长">
<div ref="progressChartRef" class="progress-chart"></div> <div ref="studyTimeChartRef" class="study-time-chart"></div>
</section> </section>
<section class="module-progress-list" aria-label="模块实训进度列表"> <section class="progress-chart-wrap" aria-label="模块任务完成情况">
<article v-for="(module, index) in displayModules" :key="module.moduleName" class="module-progress-item"> <div ref="progressChartRef" class="progress-chart"></div>
<div class="module-progress-head">
<span class="module-color" :style="{ backgroundColor: moduleColor(index) }"></span>
<strong>{{ module.moduleName }}</strong>
<span>{{ progressText(module) }}</span>
</div>
<el-progress
:percentage="progressPercent(module)"
:stroke-width="8"
:show-text="false"
:color="moduleColor(index)"
/>
</article>
</section> </section>
</div> </div>
</el-card> </el-card>
@ -92,25 +80,39 @@
import * as echarts from "echarts"; import * as echarts from "echarts";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
import * as indexApi from "@/api/index"; import * as indexApi from "@/api/index";
import { getCurrentProjectStudyTime } from "@/api/projectStudyTime";
const userStore = useUserStore(); const userStore = useUserStore();
const scoreChartRef = ref(null); const scoreChartRef = ref(null);
const studyTimeChartRef = ref(null);
const progressChartRef = ref(null); const progressChartRef = ref(null);
const report = ref({ totalScore: 0, modules: [] }); const report = ref({ totalScore: 0, modules: [] });
const studyTime = ref({}); const projectStudyTimes = ref([]);
const loading = ref(true); const loading = ref(true);
const loadError = ref(""); const loadError = ref("");
const MODULE_COLORS = ["#6958e7", "#3698ee", "#ffb83e", "#ff6f4a", "#45b982", "#39b8ce"]; const MODULE_COLORS = ["#6958e7", "#3698ee", "#ffb83e", "#ff6f4a", "#45b982", "#39b8ce"];
let scoreChart; let scoreChart;
let studyTimeChart;
let progressChart; let progressChart;
const displayModules = computed(() => report.value.modules || []); const displayModules = computed(() => report.value.modules || []);
const totalStudyMinutes = computed(() => const studySecondsByProject = computed(() => Object.fromEntries(
Object.values(studyTime.value || {}).reduce((total, value) => total + (Number(value) || 0), 0) 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)); const studyHours = computed(() => (totalStudyMinutes.value / 60).toFixed(1));
@ -183,22 +185,52 @@ const renderProgressChart = () => {
}, true); }, 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 = () => { const renderCharts = () => {
renderScoreChart(); renderScoreChart();
renderStudyTimeChart();
renderProgressChart(); renderProgressChart();
}; };
const resizeCharts = () => { const resizeCharts = () => {
scoreChart?.resize(); scoreChart?.resize();
studyTimeChart?.resize();
progressChart?.resize(); progressChart?.resize();
}; };
const loadDashboard = async () => { const loadDashboard = async () => {
loading.value = true; loading.value = true;
loadError.value = ""; loadError.value = "";
const [reportResult, timeResult] = await Promise.allSettled([ const [reportResult, projectStudyTimeResult] = await Promise.allSettled([
indexApi.getCurrentExperimentReport(), indexApi.getCurrentExperimentReport(),
indexApi.getStudyTime({ userId: userStore.userInfo.userId }) getCurrentProjectStudyTime(),
]); ]);
if (reportResult.status !== "fulfilled") { if (reportResult.status !== "fulfilled") {
@ -208,7 +240,9 @@ const loadDashboard = async () => {
} }
report.value = reportResult.value?.data || { totalScore: 0, modules: [] }; 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; loading.value = false;
await nextTick(); await nextTick();
renderCharts(); renderCharts();
@ -222,6 +256,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("resize", resizeCharts); window.removeEventListener("resize", resizeCharts);
scoreChart?.dispose(); scoreChart?.dispose();
studyTimeChart?.dispose();
progressChart?.dispose(); progressChart?.dispose();
}); });
</script> </script>
@ -282,7 +317,7 @@ onBeforeUnmount(() => {
.score-table { width: 100%; min-width: 760px; } .score-table { width: 100%; min-width: 760px; }
.score-table :deep(.el-table__cell) { white-space: nowrap; } .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 { .learning-time {
display: flex; display: flex;
@ -312,8 +347,8 @@ onBeforeUnmount(() => {
.time-value { font-size: 25px; font-weight: 800; line-height: 1; } .time-value { font-size: 25px; font-weight: 800; line-height: 1; }
.time-unit { margin-top: 5px; font-size: 13px; letter-spacing: 0.08em; } .time-unit { margin-top: 5px; font-size: 13px; letter-spacing: 0.08em; }
.progress-chart-wrap { min-width: 0; } .study-time-chart-wrap, .progress-chart-wrap { min-width: 0; }
.progress-chart { min-height: 270px; width: 100%; } .study-time-chart, .progress-chart { min-height: 270px; width: 100%; }
.module-progress-list { .module-progress-list {
display: flex; display: flex;
@ -334,7 +369,7 @@ onBeforeUnmount(() => {
.score-visual { border-right: 0; border-bottom: 1px dashed #d8e2f1; } .score-visual { border-right: 0; border-bottom: 1px dashed #d8e2f1; }
.score-table-wrap { padding: 20px 0 0; } .score-table-wrap { padding: 20px 0 0; }
.progress-layout { grid-template-columns: 180px 1fr; } .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) { @media (max-width: 760px) {
@ -345,6 +380,6 @@ onBeforeUnmount(() => {
.score-summary { left: 18px; top: 18px; } .score-summary { left: 18px; top: 18px; }
.progress-layout { grid-template-columns: 1fr; } .progress-layout { grid-template-columns: 1fr; }
.learning-time { min-height: 210px; } .learning-time { min-height: 210px; }
.module-progress-list { grid-column: auto; grid-template-columns: 1fr; } .progress-chart-wrap { grid-column: auto; }
} }
</style> </style>

@ -47,6 +47,7 @@
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { resolveProjectName, useProjectStudyTime } from "@/composables/useProjectStudyTime";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -60,6 +61,8 @@ const saving = ref(false);
const uploading = ref(false); const uploading = ref(false);
const taskSteps = computed(() => parseSteps(task.value?.steps)); const taskSteps = computed(() => parseSteps(task.value?.steps));
const stepIndex = computed(() => taskSteps.value.findIndex((item) => item.id === step.value?.id)); 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( watch(
() => [route.params.taskKey, route.params.stepId], () => [route.params.taskKey, route.params.stepId],

@ -410,6 +410,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"; import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
import { isStudentDemo } from "@/utils/studentDemo"; import { isStudentDemo } from "@/utils/studentDemo";
import { resolveProjectName, useProjectStudyTime } from "@/composables/useProjectStudyTime";
const route = useRoute(); const route = useRoute();
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
@ -420,6 +421,8 @@ const isProductDevelopmentProcess = computed(() => pageKey.value === "product-de
const isComprehensiveCaseTraining = computed(() => pageKey.value === "comprehensive-case-training"); const isComprehensiveCaseTraining = computed(() => pageKey.value === "comprehensive-case-training");
const remoteTask = ref(null); const remoteTask = ref(null);
const page = computed(() => mergeTrainingPage(getStudentTrainingPage(pageKey.value), remoteTask.value)); 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 = () => const buildInitialForm = () =>
Object.fromEntries((page.value.fields || []).map((field) => [field.key, ""])); Object.fromEntries((page.value.fields || []).map((field) => [field.key, ""]));

@ -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", () => { test("learning analysis page sends current training scores and learning activity data", () => {
assert.match(analysisSource, /暂无成绩数据,暂时无法进行AI学情分析/); assert.match(analysisSource, /暂无成绩数据,暂时无法进行AI学情分析/);
assert.match(analysisSource, /getCurrentExperimentReport/); assert.match(analysisSource, /getCurrentExperimentReport/);
assert.match(analysisSource, /getStudyTime/); assert.match(analysisSource, /getCurrentProjectStudyTime/);
assert.match(analysisSource, /getUserVisitCount/); assert.match(analysisSource, /projectStudyTimes/);
assert.match(analysisSource, /trainingScores/); assert.match(analysisSource, /trainingScores/);
assert.match(analysisSource, /learningData/); assert.match(analysisSource, /learningData/);
assert.match(analysisSource, /learningAnalysisData/); assert.match(analysisSource, /learningAnalysisData/);

@ -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/);
});
Loading…
Cancel
Save