feat: sync student navigation with task allocation

dev-QQq
chenyuan 4 weeks ago
parent 6faa6b1dab
commit 1df7b1781a

@ -81,7 +81,9 @@ import useAppStore from "@/store/modules/app";
import useUserStore from "@/store/modules/user";
import useSettingsStore from "@/store/modules/settings";
import * as indexApi from "@/api/teacher";
import { listTrainingTasks } from "@/api/trainingTask";
import { getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
import studentTrainingNavigation from "@/utils/studentTrainingNavigation.cjs";
const { proxy } = getCurrentInstance();
const router = useRouter();
const appStore = useAppStore();
@ -90,6 +92,8 @@ const settingsStore = useSettingsStore();
const showModel = ref(false);
const form = ref({});
const role = computed(() => Number(userStore.userInfo.roleId));
const { getComprehensiveTopTask } = studentTrainingNavigation;
const studentTopTask = ref(null);
const mentList = ref([
{
name: "首页",
@ -263,7 +267,7 @@ const roleList = computed(() => {
const teacherOrder = ["/index", "/analysis/index", "/ai/teaching-plan"];
return teacherOrder.map((path) => mentList.value.find((item) => item.path === path)).filter(Boolean);
}
return mentList.value.filter((item) => {
const items = mentList.value.filter((item) => {
if (currentRole === 1) {
return item.path === "/index" || item.role === 1;
}
@ -273,7 +277,26 @@ const roleList = computed(() => {
if (!item.role || item.path === "/index" || item.path === "/analysis/index") return true;
return item.role == currentRole;
});
if (currentRole !== 4 || !studentTopTask.value) return items;
const homeIndex = items.findIndex((item) => item.path === "/index");
return [
...items.slice(0, homeIndex + 1),
studentTopTask.value,
...items.slice(homeIndex + 1),
];
});
async function loadStudentTopTask() {
if (role.value !== 4) {
studentTopTask.value = null;
return;
}
try {
const res = await listTrainingTasks({ enabledOnly: true });
studentTopTask.value = getComprehensiveTopTask(res.data || []);
} catch (error) {
studentTopTask.value = null;
}
}
//
const getUserInfo = () => {
if (isStudentDemo()) {
@ -325,6 +348,7 @@ const changePassword = () => {
};
onMounted(() => {
getUserInfo();
loadStudentTopTask();
});
</script>
<style lang="scss" scoped>

@ -69,6 +69,7 @@ import usePermissionStore from "@/store/modules/permission";
import { constantRoutes, dynamicRoutes, teacherRoutes, platformAdminRoutes, schoolAdminRoutes } from "@/router/index.js";
import useUserStore from "@/store/modules/user";
import { listTrainingTasks } from "@/api/trainingTask";
import studentTrainingNavigation from "@/utils/studentTrainingNavigation.cjs";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
@ -77,6 +78,7 @@ const router = useRouter();
const appStore = useAppStore();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const { buildStudentTaskSections } = studentTrainingNavigation;
const sidebarRouters = ref([...constantRoutes]);
const visibleSidebarRouters = computed(() => filterRoutesByOrganizationMode(sidebarRouters.value));
const sidebarMenuKey = computed(() => visibleSidebarRouters.value.map((route) => route.path).join("|"));
@ -382,11 +384,13 @@ const trainingTaskKeyByPath = {
"/test/promotion": "promotion-effect-optimization",
"/assessment/diagnosis": "product-diagnosis-iteration",
};
const trainingTaskTitleByPath = ref({});
const publishedTaskKeys = ref(null);
const studentCourseBaseNav = [
{ title: "AI智能助手", path: "/assistant/index", activePrefix: "/assistant" },
];
const studentTaskSections = ref([]);
const currentCourseNav = computed(() => (isTeacherRole.value
? filterTeacherManageNavigation(teacherManageNav)
: filterStudentCourseNavigation(studentCourseNav, publishedTaskKeys.value)));
: [...studentCourseBaseNav, ...studentTaskSections.value]));
const studentIconByPath = {
"/assistant/index": "MagicStick",
"/comprehensive/index": "School",
@ -424,6 +428,7 @@ const studentIconByPath = {
"/score/index": "DataBoard",
};
const studentIconBySectionStart = [
{ path: "/training/", icon: "Document" },
{ path: "/foundation/", icon: "Collection" },
{ path: "/demand/", icon: "Search" },
{ path: "/product/", icon: "Goods" },
@ -464,49 +469,20 @@ function goStudentNav(path) {
router.push(path);
}
function getCourseNavTitle(item) {
if (!item?.path) return item?.title || "";
return trainingTaskTitleByPath.value[item.path] || item.title;
return item?.title || "";
}
async function loadTrainingTaskTitles() {
async function loadStudentTaskNavigation() {
if (!isStudentRole.value) {
trainingTaskTitleByPath.value = {};
publishedTaskKeys.value = null;
studentTaskSections.value = [];
return;
}
try {
const res = await listTrainingTasks({ enabledOnly: true });
const titleByKey = {};
(res.data || []).forEach((task) => {
if (task.taskKey && task.taskName) {
titleByKey[task.taskKey] = task.taskName;
}
});
publishedTaskKeys.value = new Set(Object.keys(titleByKey));
const titleByPath = {};
Object.entries(trainingTaskKeyByPath).forEach(([path, taskKey]) => {
if (titleByKey[taskKey]) {
titleByPath[path] = titleByKey[taskKey];
}
});
trainingTaskTitleByPath.value = titleByPath;
studentTaskSections.value = buildStudentTaskSections(res.data || []);
} catch (error) {
trainingTaskTitleByPath.value = {};
publishedTaskKeys.value = new Set();
studentTaskSections.value = [];
}
}
function filterStudentCourseNavigation(sections, allowedTaskKeys) {
if (allowedTaskKeys == null) return sections;
return sections
.map((section) => {
if (section.path) return section;
const children = (section.children || []).filter((child) => {
const taskKey = trainingTaskKeyByPath[child.path];
return !taskKey || allowedTaskKeys.has(taskKey);
});
return { ...section, children };
})
.filter((section) => section.path || section.children?.length);
}
function groupAndSortModules(modules) {
const grouped = modules.reduce((acc, item) => {
const prefix = item.module.split("-")[0].trim();
@ -597,7 +573,7 @@ const getTaskList = async () => {
await userStore.ensureSchoolProductConfig().catch(() => null);
sidebarRouters.value = [...constantRoutes, ...filterRoutesByOrganizationMode(schoolAdminRoutes)];
} else if (userStore.userInfo.roleId != 3) {
await loadTrainingTaskTitles();
await loadStudentTaskNavigation();
sidebarRouters.value = [...constantRoutes, ...dynamicRoutes];
} else {
await userStore.ensureSchoolProductConfig().catch(() => null);

@ -168,6 +168,21 @@ export const dynamicRoutes = [
},
],
},
{
path: "/training",
component: Layout,
redirect: "/index",
roles: ["student"],
hidden: true,
children: [
{
path: "task/:pageKey",
component: () => import("@/views/training/GenericTrainingPage.vue"),
name: "StudentDynamicTrainingTask",
meta: { title: "实训任务", icon: "Document", affix: true },
},
],
},
// 互联网产品开发基础
{
path: "/foundation",

@ -0,0 +1,67 @@
const COMPREHENSIVE_TASK_KEY = "comprehensive-case-training";
const KNOWN_TASK_PATHS = Object.freeze({
"new-product-survey": "/foundation/new-product-survey",
"product-development-factors": "/foundation/product-development-factors",
"product-development-process": "/foundation/product-development-process",
"industry-demand-analysis": "/demand/index",
"competitive-environment-analysis": "/demand/environment",
"market-opportunity-selection": "/product/index",
"business-feasibility": "/demand/business-feasibility",
"target-user-profile": "/demand/people",
"consumer-behavior": "/demand/consumer-behavior",
"consumer-scenario": "/demand/consumer-scenario",
"feature-conversion": "/demand/feature-conversion",
"product-value-positioning": "/product/differentiation",
"product-structure": "/product/structure",
"after-sales-service": "/product/after-sales-service",
"requirements-doc": "/product/requirements-doc",
"hardware-cost": "/product/hardware-cost",
"supplier-evaluation": "/channel/supplier",
"purchase-demand-forecast": "/channel/index",
"product-pricing": "/product/price",
"category-optimization": "/assessment/optimization",
"release-channel-evaluation": "/test/index",
"promotion-effect-optimization": "/test/promotion",
"product-diagnosis-iteration": "/assessment/diagnosis",
});
function taskSort(task) {
const sort = Number(task?.sort);
return Number.isFinite(sort) ? sort : Number.MAX_SAFE_INTEGER;
}
function taskPath(taskKey) {
return KNOWN_TASK_PATHS[taskKey] || `/training/task/${encodeURIComponent(taskKey)}`;
}
function buildStudentTaskSections(tasks) {
const sections = new Map();
[...(tasks || [])]
.filter((task) => task?.taskKey && task.taskKey !== COMPREHENSIVE_TASK_KEY)
.sort((left, right) => taskSort(left) - taskSort(right))
.forEach((task) => {
const title = task.projectName || "其他实训";
if (!sections.has(title)) sections.set(title, { title, children: [] });
sections.get(title).children.push({
title: task.taskName || task.taskKey,
path: taskPath(task.taskKey),
});
});
return [...sections.values()];
}
function getComprehensiveTopTask(tasks) {
const task = (tasks || []).find((item) => item?.taskKey === COMPREHENSIVE_TASK_KEY);
if (!task) return null;
return {
title: task.taskName || "综合实训",
path: "/comprehensive/index",
activePrefix: "/comprehensive",
};
}
module.exports = {
buildStudentTaskSections,
getComprehensiveTopTask,
};

@ -0,0 +1,48 @@
const assert = require("assert");
const {
buildStudentTaskSections,
getComprehensiveTopTask,
} = require("../src/utils/studentTrainingNavigation.cjs");
const tasks = [
{ taskKey: "comprehensive-case-training", taskName: "综合案例实训", projectName: "综合实训", sort: 1 },
{ taskKey: "market-task", taskName: "需求访谈", projectName: "市场洞察与需求分析", sort: 3 },
{ taskKey: "new-product-survey", taskName: "新产品调查与分析", projectName: "互联网产品开发基础认知", sort: 2 },
{ taskKey: "teacher-added-task", taskName: "教师新增任务", projectName: "市场洞察与需求分析", sort: 4 },
];
const sections = buildStudentTaskSections(tasks);
assert.deepStrictEqual(sections, [
{
title: "互联网产品开发基础认知",
children: [{ title: "新产品调查与分析", path: "/foundation/new-product-survey" }],
},
{
title: "市场洞察与需求分析",
children: [
{ title: "需求访谈", path: "/training/task/market-task" },
{ title: "教师新增任务", path: "/training/task/teacher-added-task" },
],
},
]);
assert.deepStrictEqual(getComprehensiveTopTask(tasks), {
title: "综合案例实训",
path: "/comprehensive/index",
activePrefix: "/comprehensive",
});
const fs = require("fs");
const path = require("path");
const frontendRoot = path.resolve(__dirname, "..");
const sidebarSource = fs.readFileSync(path.join(frontendRoot, "src/layout/components/Sidebar/index.vue"), "utf8");
const navbarSource = fs.readFileSync(path.join(frontendRoot, "src/layout/components/Navbar.vue"), "utf8");
const routerSource = fs.readFileSync(path.join(frontendRoot, "src/router/index.js"), "utf8");
assert(sidebarSource.includes("loadStudentTaskNavigation"), "student sidebar must load its task navigation from the task API");
assert(sidebarSource.includes("studentTaskSections"), "student sidebar must render dynamic task sections");
assert(navbarSource.includes("getComprehensiveTopTask"), "top navigation must derive comprehensive training from the task API");
assert(navbarSource.includes("studentTopTask"), "top navigation must hold the published comprehensive task");
assert(routerSource.includes('path: "task/:pageKey"'), "teacher-added tasks must have a generic student route");
console.log("student training navigation contract passed");

@ -25,7 +25,7 @@ assert(taskPage.includes("getTaskPublication"), "task allocation must load the s
assert(/tasks\.value\s+\.filter\(\(task\) => task\.taskKey !== comprehensiveTaskKey\)\s+\.map\(\(task\) => task\.taskKey\)/.test(taskPage), "an unconfigured class must keep comprehensive training unpublished until the teacher explicitly selects it");
assert(taskPage.includes("保存发布配置"), "task allocation must require an explicit save");
assert(api.includes("selectTaskPublicationByClassId"), "publication API missing");
assert(sidebar.includes("filterStudentCourseNavigation"), "student menu must be filtered by published training tasks");
assert(sidebar.includes("publishedTaskKeys"), "student menu must retain published task keys");
assert(sidebar.includes("buildStudentTaskSections"), "student menu must be generated from the published training task list");
assert(sidebar.includes("studentTaskSections"), "student menu must retain task-derived navigation sections");
console.log("task publication front-end contract passed");

Loading…
Cancel
Save