You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/student/index.vue

442 lines
14 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="student-home app-container">
<el-alert
v-if="loadError"
class="load-error"
type="error"
:title="loadError"
:closable="false"
show-icon
/>
<el-card class="dashboard-card score-card" v-loading="loading">
<template #header>
<div class="card-header">
<span>我的实训成绩</span>
<small>按当前教学班发布的实训模块统计</small>
</div>
</template>
<div v-if="!loadError" class="score-layout">
<section class="score-visual" aria-label="模块成绩分布">
<div ref="scoreChartRef" class="score-chart"></div>
<div class="score-summary">
<div class="score-metric score-metric--ai">
<span class="summary-label">AI评分</span>
<strong>{{ formatScore(aiScore) }}</strong>
<span class="summary-unit">分</span>
</div>
<div class="score-metric score-metric--teacher">
<span class="summary-label">老师评分</span>
<strong>{{ formatTeacherScore(teacherScore) }}</strong>
<span v-if="hasTeacherScore(teacherScore)" class="summary-unit">分</span>
</div>
</div>
</section>
<section class="score-table-wrap" aria-label="模块成绩明细">
<el-table :data="displayModules" stripe border class="score-table" empty-text="暂无实训模块">
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="moduleName" label="实训模块" min-width="280" />
<el-table-column label="成绩占比" width="116" align="center">
<template #default="scope">{{ formatWeight(scope.row.weight) }}</template>
</el-table-column>
<el-table-column label="模块得分" width="110" align="center">
<template #default="scope">{{ formatScore(scope.row.score) }}</template>
</el-table-column>
<el-table-column label="完成进度" width="138" align="center">
<template #default="scope">{{ progressText(scope.row) }}</template>
</el-table-column>
</el-table>
</section>
</div>
</el-card>
<el-card class="dashboard-card progress-card" v-loading="loading">
<template #header>
<div class="card-header">
<span>实训进度概览</span>
<small>项目学习时长与任务完成进度对比</small>
</div>
</template>
<div v-if="!loadError" class="progress-layout">
<section class="learning-time" aria-label="有效学习时间">
<div class="time-orbit">
<div class="time-value">{{ studyHours }}</div>
<div class="time-unit">HRS</div>
</div>
<strong>有效学习时间</strong>
<span>累计投入 {{ totalStudyMinutes }} 分钟</span>
</section>
<section class="study-time-chart-wrap" aria-label="项目学习时长">
<div ref="studyTimeChartRef" class="study-time-chart"></div>
</section>
<section class="progress-chart-wrap" aria-label="模块任务完成情况">
<div ref="progressChartRef" class="progress-chart"></div>
</section>
</div>
</el-card>
</div>
</template>
<script setup>
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 projectStudyTimes = ref([]);
const loading = ref(true);
const loadError = ref("");
const MODULE_COLORS = ["#1f2937", "#4b5563", "#6b7280", "#9ca3af", "#c4c9d1", "#374151"];
let scoreChart;
let studyTimeChart;
let progressChart;
const displayModules = computed(() => report.value.modules || []);
const aiScore = computed(() => report.value.aiAssessmentScore ?? report.value.aiScore ?? report.value.totalScore);
const teacherScore = computed(() => report.value.teacherScore);
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));
const formatScore = (value) => Number(value || 0).toFixed(2).replace(/\.00$/, "");
const hasTeacherScore = (value) => value !== null && value !== undefined && value !== "";
const formatTeacherScore = (value) => hasTeacherScore(value) ? formatScore(value) : "待评阅";
const formatWeight = (weight) => `${(Number(weight || 0) * 100).toFixed(0)}%`;
const moduleColor = (index) => MODULE_COLORS[index % MODULE_COLORS.length];
const progressPercent = (module) => {
const total = Number(module.totalTaskCount) || 0;
const completed = Number(module.completedTaskCount) || 0;
return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;
};
const progressText = (module) => `${Number(module.completedTaskCount) || 0}/${Number(module.totalTaskCount) || 0}`;
const renderScoreChart = () => {
if (!scoreChartRef.value) return;
scoreChart = scoreChart || echarts.init(scoreChartRef.value);
scoreChart.setOption({
tooltip: { trigger: "item", valueFormatter: (value) => `${formatScore(value)} 分` },
legend: { orient: "vertical", right: 8, top: "middle", itemWidth: 12, itemHeight: 12 },
series: [{
name: "模块得分",
type: "pie",
radius: ["42%", "72%"],
center: ["34%", "50%"],
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
data: displayModules.value.map((module, index) => ({
name: module.moduleName,
value: Number(module.score) || 0,
itemStyle: { color: moduleColor(index) }
}))
}]
}, true);
};
const renderProgressChart = () => {
if (!progressChartRef.value) return;
progressChart = progressChart || echarts.init(progressChartRef.value);
const modules = displayModules.value;
progressChart.setOption({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
legend: { top: 0, data: ["已完成", "待完成"] },
grid: { left: 24, right: 24, top: 40, bottom: 22, containLabel: true },
xAxis: { type: "value", minInterval: 1 },
yAxis: {
type: "category",
inverse: true,
axisLabel: { width: 132, overflow: "truncate" },
data: modules.map((module) => module.moduleName)
},
series: [
{
name: "已完成",
type: "bar",
stack: "tasks",
barMaxWidth: 18,
itemStyle: { color: "#1f2937", borderRadius: [9, 0, 0, 9] },
data: modules.map((module) => Number(module.completedTaskCount) || 0)
},
{
name: "待完成",
type: "bar",
stack: "tasks",
barMaxWidth: 18,
itemStyle: { color: "#e5e7eb", borderRadius: [0, 9, 9, 0] },
data: modules.map((module) => Math.max(0, (Number(module.totalTaskCount) || 0) - (Number(module.completedTaskCount) || 0)))
}
]
}, 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: "#4b5563", 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, projectStudyTimeResult] = await Promise.allSettled([
indexApi.getCurrentExperimentReport(),
getCurrentProjectStudyTime(),
]);
if (reportResult.status !== "fulfilled") {
loadError.value = "新版实训报告加载失败,请刷新页面后重试。";
loading.value = false;
return;
}
report.value = reportResult.value?.data || { totalScore: 0, modules: [] };
projectStudyTimes.value = projectStudyTimeResult.status === "fulfilled" && Array.isArray(projectStudyTimeResult.value?.data)
? projectStudyTimeResult.value.data
: [];
loading.value = false;
await nextTick();
renderCharts();
};
onMounted(() => {
window.addEventListener("resize", resizeCharts);
loadDashboard();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeCharts);
scoreChart?.dispose();
studyTimeChart?.dispose();
progressChart?.dispose();
});
</script>
<style lang="scss" scoped>
.student-home {
min-height: calc(100vh - 70px);
background: #ffffff;
padding: 0;
}
.load-error { margin-bottom: 12px; }
.dashboard-card {
margin-bottom: 14px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 10px rgba(17, 24, 39, 0.05);
:deep(.el-card__header) { padding: 13px 16px; border-bottom: 1px solid #e5e7eb; }
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
span { color: #1f2937; font-size: 15px; font-weight: 700; }
small { color: #9ca3af; font-size: 12px; }
}
.score-layout { display: grid; grid-template-columns: minmax(520px, 0.95fr) minmax(760px, 1.35fr); min-height: 330px; }
.score-visual {
position: relative;
display: flex;
min-width: 0;
border-right: 1px dashed #e5e7eb;
}
.score-chart { width: 100%; min-height: 330px; }
.score-summary {
position: absolute;
left: 28px;
top: 26px;
display: flex;
align-items: center;
gap: 18px;
flex-wrap: nowrap;
white-space: nowrap;
color: #72809a;
strong { font-size: 30px; line-height: 1; }
.summary-unit { font-weight: 600; }
}
.score-metric { display: inline-flex; align-items: baseline; gap: 4px; }
.score-metric--ai { color: #1f2937; }
.score-metric--teacher { color: #6b7280; }
.summary-label { font-size: 12px; font-weight: 600; }
.score-table-wrap { display: flex; align-items: center; min-width: 0; overflow-x: auto; padding: 8px 0 8px 24px; }
.score-table { width: 100%; min-width: 760px; }
.score-table :deep(.el-table__cell) { white-space: nowrap; }
/* 成绩表:纯白底 + 中性灰边框,无蓝色/斑马重色 */
.score-table :deep(th.el-table__cell) {
color: #1f2937;
background: #ffffff;
border-color: #e5e7eb;
font-size: 12px;
font-weight: 700;
}
.score-table :deep(td.el-table__cell) {
color: #374151;
border-color: #e5e7eb;
font-size: 12px;
}
.score-table :deep(.el-table__row--striped td.el-table__cell) {
background: #fafbfc;
}
.score-table :deep(.el-table__body tr:hover > td.el-table__cell) {
background: #f3f4f6;
}
.progress-layout { display: grid; grid-template-columns: 190px minmax(370px, 1fr) minmax(370px, 1fr); gap: 24px; align-items: stretch; }
.learning-time {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 270px;
color: #1f2937;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #fafbfc;
strong { margin-top: 16px; font-size: 14px; font-weight: 800; }
span { margin-top: 6px; font-size: 12px; color: #6b7280; }
}
.time-orbit {
display: grid;
place-content: center;
width: 108px;
height: 108px;
text-align: center;
border: 3px solid #1f2937;
border-radius: 50%;
box-shadow: inset 0 0 0 7px #f3f4f6;
}
.time-value { font-size: 25px; font-weight: 800; line-height: 1; }
.time-unit { margin-top: 5px; font-size: 12px; letter-spacing: 0.08em; color: #6b7280; }
.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;
flex-direction: column;
justify-content: center;
gap: 14px;
min-width: 0;
padding: 8px 4px;
}
.module-progress-item { min-width: 0; }
.module-progress-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; color: #6f7b91; font-size: 13px; }
.module-progress-head strong { flex: 1; overflow: hidden; color: #273858; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.module-color { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; }
@media (max-width: 1320px) {
.score-layout { grid-template-columns: 1fr; }
.score-visual { border-bottom: 1px dashed #e5e7eb; }
.score-table-wrap { padding: 20px 0 0; }
.progress-layout { grid-template-columns: 180px 1fr; }
.progress-chart-wrap { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.student-home { padding: 12px; }
.card-header { align-items: flex-start; flex-direction: column; }
.score-visual { min-height: 290px; }
.score-chart { min-height: 290px; }
.score-summary { left: 18px; top: 18px; }
.progress-layout { grid-template-columns: 1fr; }
.learning-time { min-height: 210px; }
.progress-chart-wrap { grid-column: auto; }
}
</style>
<style lang="scss">
/*
* 学生首页贴边纯白(scoped 无法触及祖先,故放在非 scoped 块):
* 1) app-main 深蓝背景图/渐变 → 纯白,padding 清零;
* 2) 外层 .index 的 10px margin 清零,内容贴边。
* 选择器以 .student-home :has 为锚,不影响其他角色/页面。
*/
#app .studentLayout section.app-main:has(.student-home) {
padding: 0 !important;
background: #ffffff !important;
background-image: none !important;
}
#app .studentLayout .app-main:has(.student-home) .index {
margin: 0 !important;
}
</style>