From 458df6542b77494df6428c97419d466dd1bc5b78 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Fri, 13 Mar 2026 01:21:49 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/coursePlan/index.js | 56 ++ src/api/coursePlan/location.js | 56 ++ src/api/coursePlan/system.js | 56 ++ .../coursePlan/components/addLocation.vue | 155 ++++ src/views/coursePlan/components/addPlan.vue | 348 +++++++++ .../coursePlan/components/addPlanSystem.vue | 138 ++++ src/views/coursePlan/index.vue | 468 ++++++++++++ src/views/coursePlan/location.vue | 161 ++++ src/views/coursePlan/mockService.js | 494 ++++++++++++ src/views/coursePlan/planSystem.vue | 156 ++++ src/views/coursePlan/课程计划页面方案.md | 704 ++++++++++++++++++ vue.config.js | 4 +- 12 files changed, 2794 insertions(+), 2 deletions(-) create mode 100644 src/api/coursePlan/index.js create mode 100644 src/api/coursePlan/location.js create mode 100644 src/api/coursePlan/system.js create mode 100644 src/views/coursePlan/components/addLocation.vue create mode 100644 src/views/coursePlan/components/addPlan.vue create mode 100644 src/views/coursePlan/components/addPlanSystem.vue create mode 100644 src/views/coursePlan/index.vue create mode 100644 src/views/coursePlan/location.vue create mode 100644 src/views/coursePlan/mockService.js create mode 100644 src/views/coursePlan/planSystem.vue create mode 100644 src/views/coursePlan/课程计划页面方案.md diff --git a/src/api/coursePlan/index.js b/src/api/coursePlan/index.js new file mode 100644 index 0000000..9a56861 --- /dev/null +++ b/src/api/coursePlan/index.js @@ -0,0 +1,56 @@ +import request from "@/utils/request"; + +function customParamsSerializer(params) { + let result = ""; + for (let key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + if (Array.isArray(params[key])) { + params[key].forEach((item, index) => { + if (item && item.key) { + result += `${key}[${index}][key]=${item.key}&${key}[${index}][op]=${item.op}&${key}[${index}][value]=${item.value}&`; + } else { + result += `${key}[${index}]=${item}&`; + } + }); + } else { + result += `${key}=${params[key]}&`; + } + } + } + return result.slice(0, -1); +} + +export function index(params, isLoading = false) { + return request({ + method: "get", + url: "/api/admin/course-plan/index", + params, + paramsSerializer: customParamsSerializer, + isLoading, + }); +} + +export function show(params, isLoading = true) { + return request({ + method: "get", + url: "/api/admin/course-plan/show", + params, + isLoading, + }); +} + +export function save(data) { + return request({ + method: "post", + url: "/api/admin/course-plan/save", + data, + }); +} + +export function destroy(params) { + return request({ + method: "get", + url: "/api/admin/course-plan/destroy", + params, + }); +} diff --git a/src/api/coursePlan/location.js b/src/api/coursePlan/location.js new file mode 100644 index 0000000..938e0e1 --- /dev/null +++ b/src/api/coursePlan/location.js @@ -0,0 +1,56 @@ +import request from "@/utils/request"; + +function customParamsSerializer(params) { + let result = ""; + for (let key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + if (Array.isArray(params[key])) { + params[key].forEach((item, index) => { + if (item && item.key) { + result += `${key}[${index}][key]=${item.key}&${key}[${index}][op]=${item.op}&${key}[${index}][value]=${item.value}&`; + } else { + result += `${key}[${index}]=${item}&`; + } + }); + } else { + result += `${key}=${params[key]}&`; + } + } + } + return result.slice(0, -1); +} + +export function index(params, isLoading = false) { + return request({ + method: "get", + url: "/api/admin/course-plan-location/index", + params, + paramsSerializer: customParamsSerializer, + isLoading, + }); +} + +export function show(params, isLoading = true) { + return request({ + method: "get", + url: "/api/admin/course-plan-location/show", + params, + isLoading, + }); +} + +export function save(data) { + return request({ + method: "post", + url: "/api/admin/course-plan-location/save", + data, + }); +} + +export function destroy(params) { + return request({ + method: "get", + url: "/api/admin/course-plan-location/destroy", + params, + }); +} diff --git a/src/api/coursePlan/system.js b/src/api/coursePlan/system.js new file mode 100644 index 0000000..145b0d2 --- /dev/null +++ b/src/api/coursePlan/system.js @@ -0,0 +1,56 @@ +import request from "@/utils/request"; + +function customParamsSerializer(params) { + let result = ""; + for (let key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + if (Array.isArray(params[key])) { + params[key].forEach((item, index) => { + if (item && item.key) { + result += `${key}[${index}][key]=${item.key}&${key}[${index}][op]=${item.op}&${key}[${index}][value]=${item.value}&`; + } else { + result += `${key}[${index}]=${item}&`; + } + }); + } else { + result += `${key}=${params[key]}&`; + } + } + } + return result.slice(0, -1); +} + +export function index(params, isLoading = false) { + return request({ + method: "get", + url: "/api/admin/course-plan-system/index", + params, + paramsSerializer: customParamsSerializer, + isLoading, + }); +} + +export function show(params, isLoading = true) { + return request({ + method: "get", + url: "/api/admin/course-plan-system/show", + params, + isLoading, + }); +} + +export function save(data) { + return request({ + method: "post", + url: "/api/admin/course-plan-system/save", + data, + }); +} + +export function destroy(params) { + return request({ + method: "get", + url: "/api/admin/course-plan-system/destroy", + params, + }); +} diff --git a/src/views/coursePlan/components/addLocation.vue b/src/views/coursePlan/components/addLocation.vue new file mode 100644 index 0000000..4c3b2a9 --- /dev/null +++ b/src/views/coursePlan/components/addLocation.vue @@ -0,0 +1,155 @@ + + + + + + + *地点名称: + + + + + + + + + 详细地址: + + + + + + + + 排序: + + + + + + + + 状态: + + + + + + + + + + 备注: + + + + + + + + + + + + diff --git a/src/views/coursePlan/components/addPlan.vue b/src/views/coursePlan/components/addPlan.vue new file mode 100644 index 0000000..bdc11e2 --- /dev/null +++ b/src/views/coursePlan/components/addPlan.vue @@ -0,0 +1,348 @@ + + + + + + + *计划年份: + + + + + + + + + + *计划体系: + + + + + + + + + + + + *课程体系: + + + + + + + + + + + + *课程名称: + + + + + + + + + + *模块/期数: + + + + + 新增一条 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 删除 + + + + 至少保留一条模块/期数;名称可不填,月份、地点、负责人必填。 + + 删除计划 + + + + + + + + + + + + diff --git a/src/views/coursePlan/components/addPlanSystem.vue b/src/views/coursePlan/components/addPlanSystem.vue new file mode 100644 index 0000000..271e8d3 --- /dev/null +++ b/src/views/coursePlan/components/addPlanSystem.vue @@ -0,0 +1,138 @@ + + + + + + + *计划体系: + + + + + + + + + 排序: + + + + + + + + 状态: + + + + + + + + + + 备注: + + + + + + + + + + + + diff --git a/src/views/coursePlan/index.vue b/src/views/coursePlan/index.vue new file mode 100644 index 0000000..0eeea7d --- /dev/null +++ b/src/views/coursePlan/index.vue @@ -0,0 +1,468 @@ + + + + + + + + + 查询 + 重置 + + + 新增计划 + 新增地点 + 新增计划体系 + + + + + + + + + + + + + + + + + {{ item.display_text }} + 负责人:{{ item.owner_name || "-" }} + + + + + + {{ item.display_text }} + + + + + - + + + + + + + + + + + + + + + diff --git a/src/views/coursePlan/location.vue b/src/views/coursePlan/location.vue new file mode 100644 index 0000000..6eaaa77 --- /dev/null +++ b/src/views/coursePlan/location.vue @@ -0,0 +1,161 @@ + + + + + + + + + + + 查询 + 重置 + + + 新增地点 + + + + + + + + + + + {{ scope.row.status === 1 ? '启用' : '禁用' }} + + + + + + + 编辑 + + 删除 + + + + + + + + + + + + + diff --git a/src/views/coursePlan/mockService.js b/src/views/coursePlan/mockService.js new file mode 100644 index 0000000..b452efc --- /dev/null +++ b/src/views/coursePlan/mockService.js @@ -0,0 +1,494 @@ +const STORAGE_KEY = "course-plan-mock-store"; + +const getCurrentYear = () => String(new Date().getFullYear()); + +const createInitialState = () => { + const currentYear = getCurrentYear(); + return { + courseTypes: [ + { id: 1, name: "初创班", sort: 1, status: 1 }, + { id: 2, name: "高研班", sort: 2, status: 1 }, + { id: 3, name: "零单班", sort: 3, status: 1 }, + { id: 4, name: "产业加速营", sort: 4, status: 1 }, + { id: 5, name: "第二课堂", sort: 5, status: 1 }, + { id: 6, name: "人才培训", sort: 6, status: 1 }, + { id: 7, name: "科技大讲堂", sort: 7, status: 1 }, + { id: 8, name: "万人培训", sort: 8, status: 1 }, + ], + planSystems: [ + { id: 1, name: "0-1", sort: 1, status: 1, remark: "初创企业培育计划" }, + { id: 2, name: "0-10", sort: 2, status: 1, remark: "成长型企业高研计划" }, + { id: 3, name: "10-100", sort: 3, status: 1, remark: "规模企业专题计划" }, + { id: 4, name: "产业结构协同创新", sort: 4, status: 1, remark: "产业升级计划" }, + { id: 5, name: "科技人才服务", sort: 5, status: 1, remark: "科技人才专项" }, + { id: 6, name: "育民营企业家万人培训", sort: 6, status: 1, remark: "民营企业家年度培训计划" }, + ], + locations: [ + { id: 1, name: "苏州", address: "苏州校区", sort: 1, status: 1, remark: "" }, + { id: 2, name: "苏州工业园区", address: "苏州工业园区教学点", sort: 2, status: 1, remark: "" }, + { id: 3, name: "苏州高新区", address: "苏州高新区教学点", sort: 3, status: 1, remark: "" }, + { id: 4, name: "上海", address: "上海教学点", sort: 4, status: 1, remark: "" }, + { id: 5, name: "深圳", address: "深圳教学点", sort: 5, status: 1, remark: "" }, + { id: 6, name: "南京", address: "南京教学点", sort: 6, status: 1, remark: "" }, + { id: 7, name: "杭州", address: "杭州教学点", sort: 7, status: 1, remark: "" }, + { id: 8, name: "无锡", address: "无锡教学点", sort: 8, status: 1, remark: "" }, + { id: 9, name: "常州", address: "常州教学点", sort: 9, status: 1, remark: "" }, + { id: 10, name: "线上", address: "线上直播", sort: 10, status: 1, remark: "" }, + { id: 11, name: "浙江", address: "浙江教学点", sort: 11, status: 1, remark: "" }, + { id: 12, name: "北京", address: "北京教学点", sort: 12, status: 1, remark: "" }, + ], + plans: [ + { + id: 1, + year: currentYear, + plan_system_id: 1, + course_type_id: 1, + course_name: "第二期高校科技成果转化班", + details: [ + { id: 101, name: "第二期", month: 5, location_id: 1, owner_name: "王老师" }, + { id: 102, name: "第二期", month: 7, location_id: 1, owner_name: "王老师" }, + { id: 103, name: "第二期", month: 9, location_id: 1, owner_name: "王老师" }, + ], + }, + { + id: 2, + year: currentYear, + plan_system_id: 1, + course_type_id: 1, + course_name: "第二期技术经理人班", + details: [ + { id: 104, name: "南大班", month: 6, location_id: 1, owner_name: "陈老师" }, + { id: 105, name: "南大班", month: 8, location_id: 1, owner_name: "陈老师" }, + { id: 106, name: "南大班", month: 10, location_id: 1, owner_name: "陈老师" }, + ], + }, + { + id: 3, + year: currentYear, + plan_system_id: 2, + course_type_id: 2, + course_name: "第七届北大光华班", + details: [ + { id: 107, name: "第六模块", month: 3, location_id: 1, owner_name: "周老师" }, + { id: 108, name: "第六模块", month: 5, location_id: 1, owner_name: "周老师" }, + { id: 109, name: "第七模块", month: 7, location_id: 1, owner_name: "周老师" }, + ], + }, + { + id: 4, + year: currentYear, + plan_system_id: 2, + course_type_id: 2, + course_name: "第八届苏大班", + details: [ + { id: 110, name: "第二模块", month: 4, location_id: 1, owner_name: "李老师" }, + { id: 111, name: "第三模块", month: 5, location_id: 1, owner_name: "李老师" }, + { id: 112, name: "第五模块", month: 6, location_id: 1, owner_name: "李老师" }, + { id: 113, name: "六模块", month: 7, location_id: 1, owner_name: "李老师" }, + { id: 114, name: "七模块", month: 9, location_id: 4, owner_name: "李老师" }, + { id: 115, name: "八模块", month: 11, location_id: 5, owner_name: "李老师" }, + { id: 116, name: "结业", month: 12, location_id: 1, owner_name: "李老师" }, + ], + }, + { + id: 5, + year: currentYear, + plan_system_id: 2, + course_type_id: 2, + course_name: "第九期中欧班", + details: [ + { id: 117, name: "开学", month: 5, location_id: 1, owner_name: "赵老师" }, + { id: 118, name: "第二模块", month: 8, location_id: 4, owner_name: "赵老师" }, + { id: 119, name: "第三模块", month: 10, location_id: 1, owner_name: "赵老师" }, + { id: 120, name: "第四模块", month: 12, location_id: 1, owner_name: "赵老师" }, + ], + }, + { + id: 6, + year: currentYear, + plan_system_id: 2, + course_type_id: 2, + course_name: "第十期商研班", + details: [ + { id: 121, name: "开学", month: 11, location_id: 1, owner_name: "孙老师" }, + ], + }, + { + id: 7, + year: currentYear, + plan_system_id: 3, + course_type_id: 3, + course_name: "第二期肇单班", + details: [ + { id: 122, name: "开学", month: 6, location_id: 1, owner_name: "钱老师" }, + ], + }, + { + id: 8, + year: currentYear, + plan_system_id: 4, + course_type_id: 4, + course_name: "AI+能源OPC加速营", + details: [ + { id: 123, name: "", month: 4, location_id: 1, owner_name: "吴老师" }, + { id: 124, name: "", month: 5, location_id: 1, owner_name: "吴老师" }, + { id: 125, name: "", month: 6, location_id: 1, owner_name: "吴老师" }, + ], + }, + { + id: 9, + year: currentYear, + plan_system_id: 4, + course_type_id: 4, + course_name: "南大OPC加速营", + details: [ + { id: 126, name: "", month: 8, location_id: 1, owner_name: "郑老师" }, + { id: 127, name: "", month: 9, location_id: 1, owner_name: "郑老师" }, + { id: 128, name: "", month: 10, location_id: 1, owner_name: "郑老师" }, + ], + }, + { + id: 10, + year: currentYear, + plan_system_id: 4, + course_type_id: 5, + course_name: "第二课堂创新沙龙", + details: [], + }, + { + id: 11, + year: currentYear, + plan_system_id: 5, + course_type_id: 6, + course_name: "宣传部培训", + details: [ + { id: 129, name: "第一期", month: 6, location_id: 1, owner_name: "冯老师" }, + { id: 130, name: "第二期", month: 9, location_id: 1, owner_name: "冯老师" }, + { id: 131, name: "第三期", month: 12, location_id: 1, owner_name: "冯老师" }, + ], + }, + { + id: 12, + year: currentYear, + plan_system_id: 5, + course_type_id: 7, + course_name: "科技大讲堂专题课", + details: [], + }, + { + id: 13, + year: currentYear, + plan_system_id: 6, + course_type_id: 8, + course_name: "第一期万人培训", + details: [ + { id: 132, name: "南京市", month: 4, location_id: 1, owner_name: "蒋老师" }, + { id: 133, name: "苏州市", month: 5, location_id: 11, owner_name: "蒋老师" }, + { id: 134, name: "无锡市", month: 6, location_id: 1, owner_name: "蒋老师" }, + { id: 135, name: "南通市", month: 7, location_id: 1, owner_name: "蒋老师" }, + { id: 136, name: "扬州市", month: 8, location_id: 1, owner_name: "蒋老师" }, + { id: 137, name: "镇江市", month: 9, location_id: 1, owner_name: "蒋老师" }, + { id: 138, name: "盐城市", month: 10, location_id: 1, owner_name: "蒋老师" }, + { id: 139, name: "淮安市", month: 11, location_id: 1, owner_name: "蒋老师" }, + { id: 140, name: "宿迁市", month: 12, location_id: 1, owner_name: "蒋老师" }, + ], + }, + { + id: 14, + year: currentYear, + plan_system_id: 6, + course_type_id: 8, + course_name: "第二期万人培训", + details: [ + { id: 141, name: "常州市", month: 5, location_id: 12, owner_name: "韩老师" }, + { id: 142, name: "泰州市", month: 8, location_id: 1, owner_name: "韩老师" }, + { id: 143, name: "徐州市", month: 9, location_id: 1, owner_name: "韩老师" }, + { id: 144, name: "徐州市", month: 11, location_id: 1, owner_name: "韩老师" }, + ], + }, + ], + }; +}; + +const clone = (data) => JSON.parse(JSON.stringify(data)); + +const getStorage = () => { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + return window.localStorage; +}; + +const loadState = () => { + const storage = getStorage(); + if (!storage) { + return createInitialState(); + } + const cache = storage.getItem(STORAGE_KEY); + if (!cache) { + const initialState = createInitialState(); + storage.setItem(STORAGE_KEY, JSON.stringify(initialState)); + return initialState; + } + try { + return JSON.parse(cache); + } catch (error) { + const initialState = createInitialState(); + storage.setItem(STORAGE_KEY, JSON.stringify(initialState)); + return initialState; + } +}; + +let store = loadState(); + +const persist = () => { + const storage = getStorage(); + if (storage) { + storage.setItem(STORAGE_KEY, JSON.stringify(store)); + } +}; + +const nextId = (list) => list.reduce((max, item) => Math.max(max, Number(item.id) || 0), 0) + 1; + +const nextDetailId = () => { + const maxId = store.plans.reduce((max, plan) => { + const detailMax = (plan.details || []).reduce((detailCurrentMax, detail) => Math.max(detailCurrentMax, Number(detail.id) || 0), 0); + return Math.max(max, detailMax); + }, 0); + return maxId + 1; +}; + +const getFilterValue = (filter, key) => { + const target = (filter || []).find((item) => item.key === key); + return target ? target.value : ""; +}; + +const applyLikeFilter = (list, filter, key) => { + const value = getFilterValue(filter, key); + if (!value) { + return list; + } + return list.filter((item) => String(item[key] || "").includes(String(value))); +}; + +const applyEqFilter = (list, filter, key) => { + const value = getFilterValue(filter, key); + if (value === "" || value === undefined || value === null) { + return list; + } + return list.filter((item) => String(item[key]) === String(value)); +}; + +const sortBySort = (list) => clone(list).sort((a, b) => { + const sortCompare = Number(a.sort || 0) - Number(b.sort || 0); + if (sortCompare !== 0) { + return sortCompare; + } + return String(a.name || "").localeCompare(String(b.name || ""), "zh-CN"); +}); + +const getPlanSystemMap = () => { + return store.planSystems.reduce((map, item) => { + map[item.id] = item; + return map; + }, {}); +}; + +const getLocationMap = () => { + return store.locations.reduce((map, item) => { + map[item.id] = item; + return map; + }, {}); +}; + +const getCourseTypeMap = () => { + return store.courseTypes.reduce((map, item) => { + map[item.id] = item; + return map; + }, {}); +}; + +const enrichPlan = (plan) => { + const planSystemMap = getPlanSystemMap(); + const locationMap = getLocationMap(); + const courseTypeMap = getCourseTypeMap(); + return { + ...clone(plan), + plan_system: clone(planSystemMap[plan.plan_system_id] || {}), + course_type: clone(courseTypeMap[plan.course_type_id] || {}), + details: (plan.details || []).map((detail) => ({ + ...clone(detail), + location: clone(locationMap[detail.location_id] || {}), + })), + }; +}; + +export function listMockCourseTypes() { + return Promise.resolve({ + data: sortBySort(store.courseTypes.filter((item) => item.status !== 0)), + total: store.courseTypes.length, + }); +} + +export function listMockPlanSystems(params = {}) { + let list = sortBySort(store.planSystems); + list = applyLikeFilter(list, params.filter, "name"); + list = applyEqFilter(list, params.filter, "status"); + return Promise.resolve({ + data: list, + total: list.length, + }); +} + +export function showMockPlanSystem({ id }) { + const item = store.planSystems.find((planSystem) => String(planSystem.id) === String(id)); + return Promise.resolve(clone(item || {})); +} + +export function saveMockPlanSystem(data) { + if (data.id) { + const index = store.planSystems.findIndex((item) => String(item.id) === String(data.id)); + if (index > -1) { + store.planSystems.splice(index, 1, { + ...store.planSystems[index], + ...clone(data), + }); + } + } else { + store.planSystems.push({ + id: nextId(store.planSystems), + name: data.name, + sort: data.sort || 0, + status: data.status === 0 ? 0 : 1, + remark: data.remark || "", + }); + } + persist(); + return Promise.resolve({ success: true }); +} + +export function destroyMockPlanSystem({ id }) { + store.planSystems = store.planSystems.filter((item) => String(item.id) !== String(id)); + store.plans = store.plans.filter((item) => String(item.plan_system_id) !== String(id)); + persist(); + return Promise.resolve({ success: true }); +} + +export function listMockLocations(params = {}) { + let list = sortBySort(store.locations); + list = applyLikeFilter(list, params.filter, "name"); + list = applyEqFilter(list, params.filter, "status"); + return Promise.resolve({ + data: list, + total: list.length, + }); +} + +export function showMockLocation({ id }) { + const item = store.locations.find((location) => String(location.id) === String(id)); + return Promise.resolve(clone(item || {})); +} + +export function saveMockLocation(data) { + if (data.id) { + const index = store.locations.findIndex((item) => String(item.id) === String(data.id)); + if (index > -1) { + store.locations.splice(index, 1, { + ...store.locations[index], + ...clone(data), + }); + } + } else { + store.locations.push({ + id: nextId(store.locations), + name: data.name, + address: data.address || "", + sort: data.sort || 0, + status: data.status === 0 ? 0 : 1, + remark: data.remark || "", + }); + } + persist(); + return Promise.resolve({ success: true }); +} + +export function destroyMockLocation({ id }) { + store.locations = store.locations.filter((item) => String(item.id) !== String(id)); + store.plans = store.plans.map((plan) => ({ + ...plan, + details: (plan.details || []).filter((detail) => String(detail.location_id) !== String(id)), + })); + persist(); + return Promise.resolve({ success: true }); +} + +export function listMockPlans({ filter = [] } = {}) { + const year = getFilterValue(filter, "year") || getCurrentYear(); + const planSystemMap = getPlanSystemMap(); + const courseTypeMap = getCourseTypeMap(); + const list = store.plans + .filter((plan) => String(plan.year) === String(year)) + .map((plan) => enrichPlan(plan)) + .sort((a, b) => { + const planSort = Number((planSystemMap[a.plan_system_id] || {}).sort || 0) - Number((planSystemMap[b.plan_system_id] || {}).sort || 0); + if (planSort !== 0) { + return planSort; + } + const courseSort = Number((courseTypeMap[a.course_type_id] || {}).sort || 0) - Number((courseTypeMap[b.course_type_id] || {}).sort || 0); + if (courseSort !== 0) { + return courseSort; + } + return String(a.course_name || "").localeCompare(String(b.course_name || ""), "zh-CN"); + }); + + return Promise.resolve({ + data: list, + total: list.length, + }); +} + +export function showMockPlan({ id }) { + const item = store.plans.find((plan) => String(plan.id) === String(id)); + return Promise.resolve(enrichPlan(item || { details: [] })); +} + +export function saveMockPlan(data) { + const details = (data.details || []).map((detail) => ({ + id: detail.id || nextDetailId(), + name: detail.name || "", + month: Number(detail.month), + location_id: detail.location_id, + owner_name: detail.owner_name || "", + })); + + if (data.id) { + const index = store.plans.findIndex((item) => String(item.id) === String(data.id)); + if (index > -1) { + store.plans.splice(index, 1, { + ...store.plans[index], + year: String(data.year), + plan_system_id: data.plan_system_id, + course_type_id: data.course_type_id, + course_name: data.course_name, + details, + }); + } + } else { + store.plans.push({ + id: nextId(store.plans), + year: String(data.year), + plan_system_id: data.plan_system_id, + course_type_id: data.course_type_id, + course_name: data.course_name, + details, + }); + } + persist(); + return Promise.resolve({ success: true }); +} + +export function destroyMockPlan({ id }) { + store.plans = store.plans.filter((item) => String(item.id) !== String(id)); + persist(); + return Promise.resolve({ success: true }); +} diff --git a/src/views/coursePlan/planSystem.vue b/src/views/coursePlan/planSystem.vue new file mode 100644 index 0000000..a759a09 --- /dev/null +++ b/src/views/coursePlan/planSystem.vue @@ -0,0 +1,156 @@ + + + + + + + + + + + 查询 + 重置 + + + 新增计划体系 + + + + + + + + + + + {{ scope.row.status === 1 ? '启用' : '禁用' }} + + + + + + + 编辑 + + 删除 + + + + + + + + + + + + + diff --git a/src/views/coursePlan/课程计划页面方案.md b/src/views/coursePlan/课程计划页面方案.md new file mode 100644 index 0000000..802ef54 --- /dev/null +++ b/src/views/coursePlan/课程计划页面方案.md @@ -0,0 +1,704 @@ +# 课程计划页面方案 + +## 1. 背景与目标 + +课程计划用于管理某一年度内,每个月份、不同计划体系、不同课程体系下预计开设的课程。该功能的核心不是单纯维护一条课程记录,而是围绕“年度计划”集中维护某门课程在 1-12 月内的开设安排。 + +本方案目标: + +- 提供一套适配当前项目风格的前端页面设计方案。 +- 明确课程计划、计划体系、地点三类数据的职责边界。 +- 给出后端表结构、接口风格、索引与约束建议,方便前后端同步实施。 + +## 2. 业务对象拆分 + +建议将课程计划拆为 4 类业务对象,而不是把所有信息都塞进一张表。 + +### 2.1 课程计划主表 + +主表只保存“这是一条什么计划”,负责描述计划的基础维度: + +- 计划年份 +- 计划体系 +- 课程体系 +- 课程名称 + +主表不直接展开存储月份、地点、负责人,而是通过明细表维护每一个模块/期数。 + +### 2.2 课程计划明细表 + +明细表对应 `模块/期数` 数组中的每一项,一条记录代表一个模块/期数计划,字段包含: + +- 名称:非必填,可为空 +- 月份:必填,范围 1-12 +- 地点:必填 +- 负责人:必填 + +这样设计的原因: + +- 一条计划可能跨多个月份。 +- 同一月份可能出现多个模块/期数。 +- 编辑时需要单独删除某一条模块/期数明细。 +- 列表页按月份渲染时,后端可以先按主表聚合、再按月份分组返回。 + +### 2.3 计划体系表 + +计划体系属于基础数据,独立维护,作为课程计划主表的下拉来源。后续如果还有排序、停用、年度适用范围等扩展,独立成表更稳定。 + +### 2.4 地点表 + +地点同样建议作为基础数据独立维护,作为课程计划明细中的下拉来源。地点后续很可能扩展为: + +- 地址详情 +- 排序 +- 是否启用 +- 联系信息 +- 容量说明 + +因此不建议仅保存为字符串。 + +## 3. 前端页面建议 + +建议在 `coursePlan` 目录下拆分为以下页面与组件: + +- `index.vue`:课程计划主页面 +- `components/addPlan.vue`:新增/编辑计划弹窗 +- `planSystem.vue`:计划体系基础数据页 +- `components/addPlanSystem.vue`:计划体系新增/编辑弹窗 +- `location.vue`:地点基础数据页 +- `components/addLocation.vue`:地点新增/编辑弹窗 + +如果一期只做主页面,也建议先按上述结构设计,避免后续把“新增地点”“新增计划体系”继续塞在当前页面中,导致页面职责过重。 + +## 4. 课程计划主页面设计 + +### 4.1 查询与功能按钮区 + +页面顶部建议保留一行查询区和操作区,沿用项目中 `lx-header + slot content` 的风格。 + +建议包含以下内容: + +- 年份查询 + - 使用 `el-date-picker` + - `type=\"year\"` + - 默认值为当前年份 +- 查询按钮 +- 重置按钮 +- 新增计划按钮 +- 新增地点按钮 +- 新增计划体系按钮 + +说明: + +- 年份是主筛选条件,应默认带上当前年,页面进入时自动查询。 +- “新增地点”“新增计划体系”可以直接打开对应弹窗,也可以跳转到独立管理页。若从用户效率考虑,建议先做弹窗方式;若从数据管理完整性考虑,建议跳转到独立页面。 + +### 4.2 主表格列设计 + +表格建议使用 `el-table`,列如下: + +- 计划体系 +- 课程体系 +- 一月 +- 二月 +- 三月 +- 四月 +- 五月 +- 六月 +- 七月 +- 八月 +- 九月 +- 十月 +- 十一月 +- 十二月 + +### 4.3 行数据组织方式 + +列表展示建议一行代表一条课程计划主表数据,即: + +- 一条主计划 = 某年 + 某计划体系 + 某课程体系 + 某课程名称 + +但考虑到列表中不单独展示“课程名称”列,而是把课程名称写进月份单元格中,所以需要把月份列数据预处理成: + +```js +{ + id: 1, + year: "2026", + plan_system_id: 2, + plan_system_name: "年度重点计划", + course_type_id: 5, + course_type_name: "企业管理体系", + course_name: "高层管理研修班", + months: { + 1: [ + { + detail_id: 11, + module_name: "第一期", + location_name: "苏州校区", + owner_name: "张三" + } + ], + 2: [], + 3: [ + { + detail_id: 12, + module_name: "第二期", + location_name: "上海教学点", + owner_name: "李四" + }, + { + detail_id: 13, + module_name: "", + location_name: "线上", + owner_name: "王五" + } + ] + } +} +``` + +这样前端渲染逻辑会比较直接,不需要在单元格中做大量即时计算。 + +### 4.4 单元格展示规则 + +每个月份单元格中,每条明细显示格式为: + +`课程名称(模块/期数)- 地点` + +具体规则: + +- 若 `模块/期数名称` 有值:显示为 `课程名称(模块名称)- 地点` +- 若 `模块/期数名称` 为空:显示为 `课程名称 - 地点` +- 同一计划的同一月份存在多条明细时,单元格内换行展示 + +建议前端统一通过格式化方法生成显示文本,例如: + +```js +formatPlanText(courseName, moduleName, locationName) { + if (moduleName) { + return `${courseName}(${moduleName})- ${locationName}` + } + return `${courseName} - ${locationName}` +} +``` + +### 4.5 单元格悬浮交互 + +鼠标移入某月份单元格时,显示浮层,内容建议如下: + +- 第一行开始逐条展示: + - `课程名称(模块/期数)- 地点` + - `负责人:xxx` +- 底部操作区: + - 编辑按钮 + +交互建议: + +- 当该单元格没有数据时,不显示浮层。 +- 当单元格有多条明细时,浮层按列表形式展示全部明细。 +- “编辑”点击后应打开当前计划的编辑弹窗,而不是只编辑单条明细,因为需求中说明“点击后弹出该计划的编辑弹窗,可在弹窗中删除某一模块/期数”。 + +建议使用 `el-popover` 或 `el-tooltip + 自定义内容` 实现。结合项目现状,优先建议 `el-popover`,因为底部有按钮交互。 + +### 4.6 合并单元格规则 + +表格中应对“计划体系”“课程体系”执行合并单元格。 + +建议规则: + +- 如果连续多行的 `计划体系 + 课程体系` 相同,则这两列合并。 +- 合并后月份列保持逐行展示。 + +注意: + +- 只有在列表接口已经按 `计划体系排序 + 课程体系排序 + 课程名称排序` 返回时,前端 `span-method` 才容易实现。 +- 如果存在同一 `计划体系 + 课程体系` 下多门不同课程,则它们仍是多行,只是前两列被合并。 + +建议后端排序: + +- `plan_system_sort ASC` +- `course_type_sort ASC` +- `course_name ASC` +- `month ASC` + +## 5. 新增/编辑计划弹窗设计 + +建议使用一个弹窗组件同时处理新增和编辑,标题分别为: + +- 新增计划 +- 编辑计划 + +### 5.1 基础字段 + +基础区字段如下: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| 计划年份 | 年份选择 | 是 | 默认当前年 | +| 计划体系 | 下拉选择 | 是 | 由计划体系接口返回 | +| 课程体系 | 下拉选择 | 是 | 复用 `@/api/course/courseType.js` 的 `index` 接口 | +| 课程名称 | 输入框 | 是 | 建议长度限制 100 | + +### 5.2 模块/期数数组区 + +该字段建议在弹窗中使用 `el-table` 或卡片列表维护,数组最少 1 条。 + +每项字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| 名称 | 输入框 | 否 | 模块名或期数名 | +| 月份 | 下拉选择 | 是 | 1-12 月 | +| 地点 | 下拉选择 | 是 | 来源于地点接口 | +| 负责人 | 输入框 | 是 | 建议先录入姓名,后续可扩展成员选择 | + +建议操作: + +- 新增一条模块/期数 +- 删除当前条目 + +校验规则: + +- 数组不能为空 +- 至少有 1 条数据 +- 每条数据的 `月份/地点/负责人` 必填 + +### 5.3 编辑弹窗底部操作 + +编辑状态下,弹窗底部除“取消”“保存”外,增加: + +- 删除计划 + +删除逻辑建议: + +- 点击“删除计划”后弹出确认框 +- 确认文案: + `删除该计划将删除所有的模块/期数,是否确认删除?` + +删除成功后: + +- 关闭弹窗 +- 刷新列表 + +## 6. 基础数据页面建议 + +### 6.1 计划体系管理 + +建议字段: + +- 名称 +- 排序 +- 状态 +- 备注 + +页面能力: + +- 列表 +- 新增 +- 编辑 +- 删除 + +### 6.2 地点管理 + +建议字段: + +- 名称 +- 详细地址 +- 排序 +- 状态 +- 备注 + +页面能力: + +- 列表 +- 新增 +- 编辑 +- 删除 + +说明: + +- 本次需求里的“地点”更接近课程计划业务内的地点基础数据,不一定完全等同于现有预约场地类型。 +- 如果现有 `book/type` 数据能直接复用,也可以不新建地点表,而是直接复用已有接口;但从语义上看,现有接口偏“预约场地类型”,未必完全适合课程计划,因此更建议新建独立地点表。 + +## 7. 前端接口建议 + +建议延续项目当前的接口命名风格,统一采用: + +- `index` +- `show` +- `save` +- `destroy` + +### 7.1 课程计划接口 + +建议新增: + +- `@/api/coursePlan/index.js` + +建议接口: + +| 方法名 | 请求方式 | 路径建议 | 说明 | +| --- | --- | --- | --- | +| `index` | GET | `/api/admin/course-plan/index` | 课程计划列表 | +| `show` | GET | `/api/admin/course-plan/show` | 查看单条计划详情 | +| `save` | POST | `/api/admin/course-plan/save` | 新增/编辑计划 | +| `destroy` | GET | `/api/admin/course-plan/destroy` | 删除计划 | + +列表查询参数建议: + +| 参数 | 说明 | +| --- | --- | +| `page` | 页码 | +| `page_size` | 每页条数 | +| `year` | 计划年份 | +| `sort_name` | 排序字段 | +| `sort_type` | 排序方式 | + +列表返回建议除基础字段外,直接返回 `months` 聚合结构,减少前端二次整理成本。 + +### 7.2 计划体系接口 + +建议新增: + +- `@/api/coursePlan/system.js` + +建议接口: + +| 方法名 | 请求方式 | 路径建议 | 说明 | +| --- | --- | --- | --- | +| `index` | GET | `/api/admin/course-plan-system/index` | 计划体系列表 | +| `show` | GET | `/api/admin/course-plan-system/show` | 计划体系详情 | +| `save` | POST | `/api/admin/course-plan-system/save` | 新增/编辑计划体系 | +| `destroy` | GET | `/api/admin/course-plan-system/destroy` | 删除计划体系 | + +### 7.3 地点接口 + +建议新增: + +- `@/api/coursePlan/location.js` + +建议接口: + +| 方法名 | 请求方式 | 路径建议 | 说明 | +| --- | --- | --- | --- | +| `index` | GET | `/api/admin/course-plan-location/index` | 地点列表 | +| `show` | GET | `/api/admin/course-plan-location/show` | 地点详情 | +| `save` | POST | `/api/admin/course-plan-location/save` | 新增/编辑地点 | +| `destroy` | GET | `/api/admin/course-plan-location/destroy` | 删除地点 | + +### 7.4 下拉接口使用建议 + +课程体系下拉直接复用: + +- `@/api/course/courseType.js` 的 `index` + +建议前端取数时增加固定筛选: + +- 状态为启用 +- 排序按 `sort ASC` + +计划体系、地点下拉也建议提供可直接用于表单的轻量列表返回。 + +## 8. 后端表结构建议 + +建议至少新增 4 张表。 + +### 8.1 课程计划主表 `course_plan` + +| 字段名 | 类型建议 | 说明 | +| --- | --- | --- | +| `id` | bigint | 主键 | +| `year` | varchar(4) 或 int | 计划年份 | +| `plan_system_id` | bigint | 计划体系 ID | +| `course_type_id` | bigint | 课程体系 ID | +| `course_name` | varchar(100) | 课程名称 | +| `sort` | int | 排序,默认 0 | +| `status` | tinyint | 状态,默认 1 | +| `remark` | varchar(255) | 备注,可空 | +| `created_by` | bigint | 创建人,可空 | +| `updated_by` | bigint | 更新人,可空 | +| `created_at` | datetime | 创建时间 | +| `updated_at` | datetime | 更新时间 | +| `deleted_at` | datetime | 软删除时间,可空 | + +建议唯一约束: + +- 如果业务上允许“同一年、同一计划体系、同一课程体系、同一课程名称”只出现一次,则增加唯一索引: + - `uniq_year_system_course_type_course_name` + +建议普通索引: + +- `idx_year` +- `idx_plan_system_id` +- `idx_course_type_id` +- `idx_year_plan_system_course_type` + +### 8.2 课程计划明细表 `course_plan_detail` + +| 字段名 | 类型建议 | 说明 | +| --- | --- | --- | +| `id` | bigint | 主键 | +| `plan_id` | bigint | 关联 `course_plan.id` | +| `module_name` | varchar(100) | 模块/期数名称,可空 | +| `month` | tinyint | 月份,1-12 | +| `location_id` | bigint | 地点 ID | +| `owner_name` | varchar(50) | 负责人 | +| `sort` | int | 排序,默认 0 | +| `remark` | varchar(255) | 备注,可空 | +| `created_at` | datetime | 创建时间 | +| `updated_at` | datetime | 更新时间 | +| `deleted_at` | datetime | 软删除时间,可空 | + +建议约束: + +- `month` 限制为 1-12 +- `plan_id` 必须存在 +- `location_id` 必须存在 + +建议索引: + +- `idx_plan_id` +- `idx_month` +- `idx_plan_id_month` +- `idx_location_id` + +说明: + +- 同一计划下同一月份允许出现多条明细,因此不建议对 `plan_id + month` 建唯一索引。 + +### 8.3 计划体系表 `course_plan_system` + +| 字段名 | 类型建议 | 说明 | +| --- | --- | --- | +| `id` | bigint | 主键 | +| `name` | varchar(100) | 计划体系名称 | +| `sort` | int | 排序,默认 0 | +| `status` | tinyint | 状态,默认 1 | +| `remark` | varchar(255) | 备注,可空 | +| `created_at` | datetime | 创建时间 | +| `updated_at` | datetime | 更新时间 | +| `deleted_at` | datetime | 软删除时间,可空 | + +建议约束: + +- `name` 唯一,避免重复维护相同计划体系 + +### 8.4 地点表 `course_plan_location` + +| 字段名 | 类型建议 | 说明 | +| --- | --- | --- | +| `id` | bigint | 主键 | +| `name` | varchar(100) | 地点名称 | +| `address` | varchar(255) | 详细地址,可空 | +| `sort` | int | 排序,默认 0 | +| `status` | tinyint | 状态,默认 1 | +| `remark` | varchar(255) | 备注,可空 | +| `created_at` | datetime | 创建时间 | +| `updated_at` | datetime | 更新时间 | +| `deleted_at` | datetime | 软删除时间,可空 | + +建议约束: + +- `name` 可考虑唯一,避免地点重名引发误选 + +## 9. 表关系与删除策略建议 + +关系建议如下: + +- `course_plan.plan_system_id -> course_plan_system.id` +- `course_plan.course_type_id -> course_type.id` +- `course_plan_detail.plan_id -> course_plan.id` +- `course_plan_detail.location_id -> course_plan_location.id` + +删除策略建议: + +- 删除课程计划主表时,同时删除全部明细 +- 删除计划体系前,需校验是否已被课程计划使用 +- 删除地点前,需校验是否已被课程计划明细使用 + +若后端采用软删除: + +- 主表删除时同步软删除明细 +- 基础表删除前仍需做引用检查 + +## 10. 建议的数据返回结构 + +为减少前端拼装成本,建议 `course-plan/index` 直接返回可渲染结构: + +```json +{ + "data": [ + { + "id": 1, + "year": "2026", + "plan_system_id": 1, + "plan_system_name": "年度重点计划", + "course_type_id": 2, + "course_type_name": "经营管理体系", + "course_name": "企业经营实战课", + "months": { + "1": [ + { + "detail_id": 10, + "module_name": "第一期", + "month": 1, + "location_id": 3, + "location_name": "本部校区", + "owner_name": "张老师" + } + ], + "2": [], + "3": [] + } + } + ], + "total": 1 +} +``` + +这样前端可以直接: + +- 遍历 1-12 月列 +- 读取 `row.months[month]` +- 渲染多行文本 +- 在悬浮层中使用同一份数据 + +## 11. 前端实现建议 + +### 11.1 列表页实现顺序 + +建议优先顺序: + +1. 完成年份查询与主表格静态布局 +2. 打通课程计划列表接口 +3. 完成月份列渲染 +4. 实现 `计划体系 + 课程体系` 合并单元格 +5. 接入单元格悬浮层 +6. 完成新增/编辑计划弹窗 +7. 完成计划体系和地点基础数据管理 + +### 11.2 弹窗数组区实现方式 + +建议直接参考当前项目中弹窗内 `el-table` 维护数组的方式,不建议在一期引入过于复杂的拖拽方案。原因是本需求只是“增删模块/期数条目”,不涉及复杂表单搭建。 + +建议前端表单结构: + +```js +form: { + id: "", + year: "2026", + plan_system_id: "", + course_type_id: "", + course_name: "", + details: [ + { + id: "", + module_name: "", + month: "", + location_id: "", + owner_name: "" + } + ] +} +``` + +### 11.3 保存接口提交结构 + +保存建议直接提交主表和明细数组: + +```json +{ + "id": 1, + "year": "2026", + "plan_system_id": 2, + "course_type_id": 5, + "course_name": "高层管理研修班", + "details": [ + { + "id": 11, + "module_name": "第一期", + "month": 3, + "location_id": 8, + "owner_name": "张三" + }, + { + "module_name": "", + "month": 6, + "location_id": 9, + "owner_name": "李四" + } + ] +} +``` + +后端处理建议: + +- 有 `id` 的明细按更新处理 +- 没有 `id` 的明细按新增处理 +- 编辑时前端未提交的旧明细,可视为删除,或由前端显式传删除标识;二者选其一并保持统一 + +更推荐: + +- 前端显式维护当前剩余明细列表 +- 后端保存时以 `plan_id` 为维度做差异同步 + +## 12. 风险点与注意事项 + +### 12.1 合并单元格前提 + +如果接口返回顺序不稳定,前端合并单元格会错乱,因此后端必须按统一顺序返回。 + +### 12.2 同月多条数据展示 + +单元格内多条明细换行后,高度会被撑开,建议: + +- 允许行高自适应 +- 单元格设置适当 padding +- 当数据过多时提供 `max-height` 或悬浮层查看更多 + +### 12.3 负责人字段 + +当前需求中负责人是字符串字段,落地最快;但如果后续希望与员工体系关联,建议预留扩展方案: + +- 短期先存 `owner_name` +- 中长期新增 `owner_id` + +### 12.4 地点是否复用旧表 + +如果后端确认现有预约场地类型可直接复用,则本方案中的地点表可以取消,改为复用旧接口。若复用,需要额外确认: + +- 旧数据是否都可作为课程计划地点 +- 字段含义是否匹配 +- 停用逻辑是否一致 + +## 13. 推荐一期落地范围 + +建议一期范围: + +- 课程计划主页面 +- 新增/编辑计划弹窗 +- 计划体系基础管理 +- 地点基础管理 +- 年份筛选 +- 月份表格展示 +- 单元格悬浮编辑 +- 删除计划确认 + +建议暂缓项: + +- 负责人关联员工选择器 +- 导入导出 +- 批量复制上一年度计划 +- 月份维度统计分析 + +## 14. 与现有项目实现风格的对应关系 + +本方案与现有项目风格保持一致,主要可复用如下思路: + +- 列表页骨架可参考 `src/views/course/types.vue` +- 基础配置页可参考 `src/views/courseTypeConfig/index.vue` +- 动态数组表单可参考 `src/views/dataMenu/components/addInfo.vue` +- 合并单元格可参考 `src/views/statistics/index.vue` +- 地点管理页风格可参考 `src/views/book/type.vue` + +这样可以降低前端开发成本,也能让课程计划功能在交互上与现有系统保持统一。 diff --git a/vue.config.js b/vue.config.js index 8cb3047..5bf5d83 100644 --- a/vue.config.js +++ b/vue.config.js @@ -28,9 +28,9 @@ module.exports = { */ publicPath: process.env.ENV === 'staging' ? '/admin' : '/admin', // 测试 - // outputDir: '/Users/mac/Documents/朗业/2025/s-苏州科技商学院/wx.sstbc.com/public/admin', + outputDir: '/Users/mac/Documents/朗业/2025/s-苏州科技商学院/wx.sstbc.com/public/admin', // 正式 - outputDir: '/Users/mac/Documents/朗业/2024/s-苏州科技商学院/wx.sstbc.com/public/admin', + // outputDir: '/Users/mac/Documents/朗业/2024/s-苏州科技商学院/wx.sstbc.com/public/admin', assetsDir: 'static', css: { loaderOptions: { // 向 CSS 相关的 loader 传递选项