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 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,

@ -50,7 +50,7 @@
<template #header>
<div class="card-header">
<span>实训进度概览</span>
<small>完成任务越多,模块进度越靠前</small>
<small>项目学习时长与任务完成进度对比</small>
</div>
</template>
@ -64,24 +64,12 @@
<span>累计投入 {{ totalStudyMinutes }} 分钟</span>
</section>
<section class="progress-chart-wrap" aria-label="模块任务完成情况">
<div ref="progressChartRef" class="progress-chart"></div>
<section class="study-time-chart-wrap" aria-label="项目学习时长">
<div ref="studyTimeChartRef" class="study-time-chart"></div>
</section>
<section class="module-progress-list" aria-label="模块实训进度列表">
<article v-for="(module, index) in displayModules" :key="module.moduleName" class="module-progress-item">
<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 class="progress-chart-wrap" aria-label="模块任务完成情况">
<div ref="progressChartRef" class="progress-chart"></div>
</section>
</div>
</el-card>
@ -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();
});
</script>
@ -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; }
}
</style>

@ -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],

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

@ -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/);

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