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 useUserStore from "@/store/modules/user";
import useSettingsStore from "@/store/modules/settings"; import useSettingsStore from "@/store/modules/settings";
import * as indexApi from "@/api/teacher"; import * as indexApi from "@/api/teacher";
import { listTrainingTasks } from "@/api/trainingTask";
import { getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo"; import { getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
import studentTrainingNavigation from "@/utils/studentTrainingNavigation.cjs";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const router = useRouter(); const router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
@ -90,6 +92,8 @@ const settingsStore = useSettingsStore();
const showModel = ref(false); const showModel = ref(false);
const form = ref({}); const form = ref({});
const role = computed(() => Number(userStore.userInfo.roleId)); const role = computed(() => Number(userStore.userInfo.roleId));
const { getComprehensiveTopTask } = studentTrainingNavigation;
const studentTopTask = ref(null);
const mentList = ref([ const mentList = ref([
{ {
name: "首页", name: "首页",
@ -263,7 +267,7 @@ const roleList = computed(() => {
const teacherOrder = ["/index", "/analysis/index", "/ai/teaching-plan"]; const teacherOrder = ["/index", "/analysis/index", "/ai/teaching-plan"];
return teacherOrder.map((path) => mentList.value.find((item) => item.path === path)).filter(Boolean); 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) { if (currentRole === 1) {
return item.path === "/index" || item.role === 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; if (!item.role || item.path === "/index" || item.path === "/analysis/index") return true;
return item.role == currentRole; 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 = () => { const getUserInfo = () => {
if (isStudentDemo()) { if (isStudentDemo()) {
@ -323,9 +346,10 @@ const changePassword = () => {
}) })
.catch(() => {}); .catch(() => {});
}; };
onMounted(() => { onMounted(() => {
getUserInfo(); getUserInfo();
}); loadStudentTopTask();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.navbar { .navbar {

@ -69,6 +69,7 @@ import usePermissionStore from "@/store/modules/permission";
import { constantRoutes, dynamicRoutes, teacherRoutes, platformAdminRoutes, schoolAdminRoutes } from "@/router/index.js"; import { constantRoutes, dynamicRoutes, teacherRoutes, platformAdminRoutes, schoolAdminRoutes } from "@/router/index.js";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
import { listTrainingTasks } from "@/api/trainingTask"; import { listTrainingTasks } from "@/api/trainingTask";
import studentTrainingNavigation from "@/utils/studentTrainingNavigation.cjs";
import * as ElementPlusIconsVue from "@element-plus/icons-vue"; import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const userStore = useUserStore(); const userStore = useUserStore();
@ -77,6 +78,7 @@ const router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore(); const permissionStore = usePermissionStore();
const { buildStudentTaskSections } = studentTrainingNavigation;
const sidebarRouters = ref([...constantRoutes]); const sidebarRouters = ref([...constantRoutes]);
const visibleSidebarRouters = computed(() => filterRoutesByOrganizationMode(sidebarRouters.value)); const visibleSidebarRouters = computed(() => filterRoutesByOrganizationMode(sidebarRouters.value));
const sidebarMenuKey = computed(() => visibleSidebarRouters.value.map((route) => route.path).join("|")); const sidebarMenuKey = computed(() => visibleSidebarRouters.value.map((route) => route.path).join("|"));
@ -382,11 +384,13 @@ const trainingTaskKeyByPath = {
"/test/promotion": "promotion-effect-optimization", "/test/promotion": "promotion-effect-optimization",
"/assessment/diagnosis": "product-diagnosis-iteration", "/assessment/diagnosis": "product-diagnosis-iteration",
}; };
const trainingTaskTitleByPath = ref({}); const studentCourseBaseNav = [
const publishedTaskKeys = ref(null); { title: "AI智能助手", path: "/assistant/index", activePrefix: "/assistant" },
];
const studentTaskSections = ref([]);
const currentCourseNav = computed(() => (isTeacherRole.value const currentCourseNav = computed(() => (isTeacherRole.value
? filterTeacherManageNavigation(teacherManageNav) ? filterTeacherManageNavigation(teacherManageNav)
: filterStudentCourseNavigation(studentCourseNav, publishedTaskKeys.value))); : [...studentCourseBaseNav, ...studentTaskSections.value]));
const studentIconByPath = { const studentIconByPath = {
"/assistant/index": "MagicStick", "/assistant/index": "MagicStick",
"/comprehensive/index": "School", "/comprehensive/index": "School",
@ -424,6 +428,7 @@ const studentIconByPath = {
"/score/index": "DataBoard", "/score/index": "DataBoard",
}; };
const studentIconBySectionStart = [ const studentIconBySectionStart = [
{ path: "/training/", icon: "Document" },
{ path: "/foundation/", icon: "Collection" }, { path: "/foundation/", icon: "Collection" },
{ path: "/demand/", icon: "Search" }, { path: "/demand/", icon: "Search" },
{ path: "/product/", icon: "Goods" }, { path: "/product/", icon: "Goods" },
@ -464,49 +469,20 @@ function goStudentNav(path) {
router.push(path); router.push(path);
} }
function getCourseNavTitle(item) { function getCourseNavTitle(item) {
if (!item?.path) return item?.title || ""; return item?.title || "";
return trainingTaskTitleByPath.value[item.path] || item.title;
} }
async function loadTrainingTaskTitles() { async function loadStudentTaskNavigation() {
if (!isStudentRole.value) { if (!isStudentRole.value) {
trainingTaskTitleByPath.value = {}; studentTaskSections.value = [];
publishedTaskKeys.value = null;
return; return;
} }
try { try {
const res = await listTrainingTasks({ enabledOnly: true }); const res = await listTrainingTasks({ enabledOnly: true });
const titleByKey = {}; studentTaskSections.value = buildStudentTaskSections(res.data || []);
(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;
} catch (error) { } catch (error) {
trainingTaskTitleByPath.value = {}; studentTaskSections.value = [];
publishedTaskKeys.value = new Set();
} }
} }
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) { function groupAndSortModules(modules) {
const grouped = modules.reduce((acc, item) => { const grouped = modules.reduce((acc, item) => {
const prefix = item.module.split("-")[0].trim(); const prefix = item.module.split("-")[0].trim();
@ -597,7 +573,7 @@ const getTaskList = async () => {
await userStore.ensureSchoolProductConfig().catch(() => null); await userStore.ensureSchoolProductConfig().catch(() => null);
sidebarRouters.value = [...constantRoutes, ...filterRoutesByOrganizationMode(schoolAdminRoutes)]; sidebarRouters.value = [...constantRoutes, ...filterRoutesByOrganizationMode(schoolAdminRoutes)];
} else if (userStore.userInfo.roleId != 3) { } else if (userStore.userInfo.roleId != 3) {
await loadTrainingTaskTitles(); await loadStudentTaskNavigation();
sidebarRouters.value = [...constantRoutes, ...dynamicRoutes]; sidebarRouters.value = [...constantRoutes, ...dynamicRoutes];
} else { } else {
await userStore.ensureSchoolProductConfig().catch(() => null); 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", 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(/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(taskPage.includes("保存发布配置"), "task allocation must require an explicit save");
assert(api.includes("selectTaskPublicationByClassId"), "publication API missing"); assert(api.includes("selectTaskPublicationByClassId"), "publication API missing");
assert(sidebar.includes("filterStudentCourseNavigation"), "student menu must be filtered by published training tasks"); assert(sidebar.includes("buildStudentTaskSections"), "student menu must be generated from the published training task list");
assert(sidebar.includes("publishedTaskKeys"), "student menu must retain published task keys"); assert(sidebar.includes("studentTaskSections"), "student menu must retain task-derived navigation sections");
console.log("task publication front-end contract passed"); console.log("task publication front-end contract passed");

Loading…
Cancel
Save